@sigrun/todo 0.0.0-stage → 0.1.2
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/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +55 -2
- package/THIRD_PARTY_NOTICES.md +30 -0
- package/dist/freshness.d.ts +67 -0
- package/dist/freshness.js +135 -0
- package/dist/graph.d.ts +9 -0
- package/dist/graph.js +40 -0
- package/dist/hooks.d.ts +38 -0
- package/dist/hooks.js +65 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +62 -0
- package/dist/log.d.ts +77 -0
- package/dist/log.js +153 -0
- package/dist/panel.d.ts +39 -0
- package/dist/panel.js +152 -0
- package/dist/section.d.ts +8 -0
- package/dist/section.js +9 -0
- package/dist/state.d.ts +74 -0
- package/dist/state.js +282 -0
- package/dist/tool.d.ts +35 -0
- package/dist/tool.js +168 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.js +7 -0
- package/package.json +57 -5
package/dist/log.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript replay + per-conversation cache for `@sigrun/todo`.
|
|
3
|
+
*
|
|
4
|
+
* The transcript is the source of truth: every state-changing `todo` call
|
|
5
|
+
* returns the full post-change snapshot in its tool-result `details`, so the
|
|
6
|
+
* newest such snapshot in the fork-aware history IS the list. {@link replay}
|
|
7
|
+
* reads that snapshot; {@link TodoLog} caches it per conversation so a tool
|
|
8
|
+
* round does not re-scan the history between its own calls.
|
|
9
|
+
*
|
|
10
|
+
* The cache deliberately runs ahead of the transcript between a `todo` call
|
|
11
|
+
* and the round settling: an aborted call's change is uncommitted, and
|
|
12
|
+
* {@link TodoLog.settle} (called when the round ends) forgets the dirty
|
|
13
|
+
* entry so the next {@link TodoLog.get} re-reads the transcript without it.
|
|
14
|
+
*/
|
|
15
|
+
import { changesState } from "./state.js";
|
|
16
|
+
import { EMPTY_STATE } from "./types.js";
|
|
17
|
+
/** Page size for the host scan. */
|
|
18
|
+
const PAGE_SIZE = 100;
|
|
19
|
+
/**
|
|
20
|
+
* The {@link EntryReader} over a sigrun host's fork-aware transcript read,
|
|
21
|
+
* paging `host.conversations.entries` newest-first until exhausted.
|
|
22
|
+
*/
|
|
23
|
+
export function hostEntryReader(host) {
|
|
24
|
+
return async function* (conversationId) {
|
|
25
|
+
let cursor;
|
|
26
|
+
for (;;) {
|
|
27
|
+
const page = await host.conversations.entries(conversationId, { order: "descending" }, PAGE_SIZE, cursor);
|
|
28
|
+
for (const entry of page.items)
|
|
29
|
+
yield entry;
|
|
30
|
+
if (page.next === undefined)
|
|
31
|
+
return;
|
|
32
|
+
cursor = page.next;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The snapshot carried in one tool-result entry's `details`, or `undefined`
|
|
38
|
+
* when the entry is not a usable todo snapshot (wrong kind, wrong tool,
|
|
39
|
+
* error result, malformed details).
|
|
40
|
+
*/
|
|
41
|
+
function snapshotOf(entry) {
|
|
42
|
+
if (entry.kind !== "pi.tool-result")
|
|
43
|
+
return undefined;
|
|
44
|
+
const message = entry.model?.[0];
|
|
45
|
+
if (message?.role !== "toolResult" || message.toolName !== "todo" || message.isError !== false) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const details = message.details;
|
|
49
|
+
if (details === undefined ||
|
|
50
|
+
typeof details !== "object" ||
|
|
51
|
+
!Array.isArray(details.tasks) ||
|
|
52
|
+
typeof details.nextId !== "number") {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return { tasks: details.tasks, nextId: details.nextId };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The todo list of one conversation: the newest committed `todo` snapshot in
|
|
59
|
+
* its fork-aware history. Walks newest→oldest; a `pi.reset` entry ends the
|
|
60
|
+
* walk (history before the reset is not this list's history); `pi.compaction`
|
|
61
|
+
* and everything else is walked past. No snapshot → {@link EMPTY_STATE}.
|
|
62
|
+
*/
|
|
63
|
+
export async function replay(read, conversationId) {
|
|
64
|
+
for await (const entry of read(conversationId)) {
|
|
65
|
+
if (entry.kind === "pi.reset")
|
|
66
|
+
return EMPTY_STATE;
|
|
67
|
+
const snapshot = snapshotOf(entry);
|
|
68
|
+
if (snapshot !== undefined)
|
|
69
|
+
return snapshot;
|
|
70
|
+
}
|
|
71
|
+
return EMPTY_STATE;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The per-conversation todo cache in front of the transcript.
|
|
75
|
+
*
|
|
76
|
+
* `get` seeds the cache from the transcript once per conversation;
|
|
77
|
+
* concurrent callers share ONE seeding promise (the transcript is scanned
|
|
78
|
+
* once no matter how many of a round's tool calls race). `mutate` awaits
|
|
79
|
+
* that seed, then reads-applies-writes synchronously — the NO-`await`
|
|
80
|
+
* window between the read and the write is what serializes parallel calls:
|
|
81
|
+
* no two mutations can observe the same state. A mutation whose op changes
|
|
82
|
+
* state ({@link changesState}) marks the entry dirty.
|
|
83
|
+
*
|
|
84
|
+
* `settle` ends a round: a dirty entry is dropped (the transcript either
|
|
85
|
+
* holds its snapshot by then or the call was aborted, in which case the
|
|
86
|
+
* uncommitted change must disappear) and reports `true`; a clean entry is
|
|
87
|
+
* kept and reports `false`. `forget` drops the entry unconditionally.
|
|
88
|
+
*/
|
|
89
|
+
export class TodoLog {
|
|
90
|
+
#read;
|
|
91
|
+
#cache = new Map();
|
|
92
|
+
#seeding = new Map();
|
|
93
|
+
constructor(read) {
|
|
94
|
+
this.#read = read;
|
|
95
|
+
}
|
|
96
|
+
/** The conversation's current state, seeding from the transcript once. */
|
|
97
|
+
async get(id) {
|
|
98
|
+
const cached = this.#cache.get(id);
|
|
99
|
+
if (cached !== undefined)
|
|
100
|
+
return cached.state;
|
|
101
|
+
return await this.#seed(id);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Apply `f` to the conversation's current state. Serialized with every
|
|
105
|
+
* other `mutate` of this conversation: after the seed resolves, the
|
|
106
|
+
* read-apply-write below contains no `await`, so it is atomic.
|
|
107
|
+
*/
|
|
108
|
+
async mutate(id, f) {
|
|
109
|
+
await this.#seed(id);
|
|
110
|
+
// No await from here to the write — this IS the serializer.
|
|
111
|
+
const current = this.#cache.get(id) ?? { state: EMPTY_STATE, dirty: false };
|
|
112
|
+
const { state, op } = f(current.state);
|
|
113
|
+
this.#cache.set(id, { state, dirty: current.dirty || changesState(op) });
|
|
114
|
+
return { state, op };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* End the conversation's round: a dirty entry is dropped (return `true`)
|
|
118
|
+
* so the next `get` re-reads the transcript — the committed snapshot, or
|
|
119
|
+
* the state without an aborted call's uncommitted change. Clean → `false`.
|
|
120
|
+
*/
|
|
121
|
+
settle(id) {
|
|
122
|
+
const cached = this.#cache.get(id);
|
|
123
|
+
if (cached?.dirty !== true)
|
|
124
|
+
return false;
|
|
125
|
+
this.#cache.delete(id);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
/** Drop the conversation's cache entry; the next `get` re-seeds. */
|
|
129
|
+
forget(id) {
|
|
130
|
+
this.#cache.delete(id);
|
|
131
|
+
}
|
|
132
|
+
/** Seed the cache from the transcript once per id, sharing one promise. */
|
|
133
|
+
#seed(id) {
|
|
134
|
+
const cached = this.#cache.get(id);
|
|
135
|
+
if (cached !== undefined)
|
|
136
|
+
return Promise.resolve(cached.state);
|
|
137
|
+
let seeding = this.#seeding.get(id);
|
|
138
|
+
if (seeding === undefined) {
|
|
139
|
+
seeding = replay(this.#read, id).then((state) => {
|
|
140
|
+
this.#cache.set(id, { state, dirty: false });
|
|
141
|
+
this.#seeding.delete(id);
|
|
142
|
+
return state;
|
|
143
|
+
}, (error) => {
|
|
144
|
+
// A rejected seed must NOT be cached: it stays retried by the
|
|
145
|
+
// next `get`, not pinned for the process lifetime.
|
|
146
|
+
this.#seeding.delete(id);
|
|
147
|
+
throw error;
|
|
148
|
+
});
|
|
149
|
+
this.#seeding.set(id, seeding);
|
|
150
|
+
}
|
|
151
|
+
return seeding;
|
|
152
|
+
}
|
|
153
|
+
}
|
package/dist/panel.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Todos panel (Task 9): a `perConversation` panel over the
|
|
3
|
+
* conversation's {@link TodoLog}.
|
|
4
|
+
*
|
|
5
|
+
* - `perConversation: true` and NO `instanceKey`: the channels attach the
|
|
6
|
+
* panel with `params: { conversationId }` of the open conversation, and
|
|
7
|
+
* the default canonical-JSON instance key is what the webui hashes —
|
|
8
|
+
* declaring one would break live updates.
|
|
9
|
+
* - Every render/data checks the CALLER's access: the conversationId is
|
|
10
|
+
* client-supplied, so an unknown conversation (a `meta` throw) or a
|
|
11
|
+
* `none` verdict hides the panel outright.
|
|
12
|
+
* - Deleted tasks are tombstones (out of `items` AND out of `total`);
|
|
13
|
+
* completed rows stay visible, `tone: "neutral"`, never `quiet`.
|
|
14
|
+
* - The tree is a `page` (no sidebar — panel placement) with a markdown
|
|
15
|
+
* `done/total` line and a list `collection` bound to `/items`.
|
|
16
|
+
*/
|
|
17
|
+
import type { ConversationId, JsonObject } from "@earendil-works/pi-durable";
|
|
18
|
+
import type { SigrunHost, SurfaceDef } from "@sigrun/sigrun/extension";
|
|
19
|
+
import type { TodoLog } from "./log.js";
|
|
20
|
+
export interface TodoPanelDeps {
|
|
21
|
+
readonly host: SigrunHost;
|
|
22
|
+
readonly log: TodoLog;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The conversation id of a `perConversation` params object, or `undefined`:
|
|
26
|
+
* a positive integer `conversationId`, nothing else. The value is
|
|
27
|
+
* client-supplied; a brand-cast number must still be an id, so only a
|
|
28
|
+
* positive integer is accepted. (The cast stamps pi-durable's branded
|
|
29
|
+
* `ConversationId` — the runtime check above is the whole guard.)
|
|
30
|
+
*/
|
|
31
|
+
export declare function conversationIdOf(params: JsonObject): ConversationId | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The instance key a `changed` notification is routed by. This must equal
|
|
34
|
+
* `canonicalJson({ conversationId })` — the default key for the params the
|
|
35
|
+
* channels attach — which for this single numeric key is plain
|
|
36
|
+
* `JSON.stringify` (pinned against `canonicalJson` by the panel test).
|
|
37
|
+
*/
|
|
38
|
+
export declare function panelKey(id: ConversationId): string;
|
|
39
|
+
export declare function createTodoPanel(deps: TodoPanelDeps): SurfaceDef<"panel">;
|
package/dist/panel.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Todos panel (Task 9): a `perConversation` panel over the
|
|
3
|
+
* conversation's {@link TodoLog}.
|
|
4
|
+
*
|
|
5
|
+
* - `perConversation: true` and NO `instanceKey`: the channels attach the
|
|
6
|
+
* panel with `params: { conversationId }` of the open conversation, and
|
|
7
|
+
* the default canonical-JSON instance key is what the webui hashes —
|
|
8
|
+
* declaring one would break live updates.
|
|
9
|
+
* - Every render/data checks the CALLER's access: the conversationId is
|
|
10
|
+
* client-supplied, so an unknown conversation (a `meta` throw) or a
|
|
11
|
+
* `none` verdict hides the panel outright.
|
|
12
|
+
* - Deleted tasks are tombstones (out of `items` AND out of `total`);
|
|
13
|
+
* completed rows stay visible, `tone: "neutral"`, never `quiet`.
|
|
14
|
+
* - The tree is a `page` (no sidebar — panel placement) with a markdown
|
|
15
|
+
* `done/total` line and a list `collection` bound to `/items`.
|
|
16
|
+
*/
|
|
17
|
+
/** The empty panel: no rows, whatever the access path that led here. */
|
|
18
|
+
const EMPTY_DATA = { done: 0, total: 0, summary: "**0/0** done", items: [] };
|
|
19
|
+
/**
|
|
20
|
+
* The conversation id of a `perConversation` params object, or `undefined`:
|
|
21
|
+
* a positive integer `conversationId`, nothing else. The value is
|
|
22
|
+
* client-supplied; a brand-cast number must still be an id, so only a
|
|
23
|
+
* positive integer is accepted. (The cast stamps pi-durable's branded
|
|
24
|
+
* `ConversationId` — the runtime check above is the whole guard.)
|
|
25
|
+
*/
|
|
26
|
+
export function conversationIdOf(params) {
|
|
27
|
+
const id = params.conversationId;
|
|
28
|
+
if (typeof id !== "number" || !Number.isInteger(id) || id <= 0)
|
|
29
|
+
return undefined;
|
|
30
|
+
// A positive integer IS a ConversationId; the brand carries no runtime payload.
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The instance key a `changed` notification is routed by. This must equal
|
|
35
|
+
* `canonicalJson({ conversationId })` — the default key for the params the
|
|
36
|
+
* channels attach — which for this single numeric key is plain
|
|
37
|
+
* `JSON.stringify` (pinned against `canonicalJson` by the panel test).
|
|
38
|
+
*/
|
|
39
|
+
export function panelKey(id) {
|
|
40
|
+
return JSON.stringify({ conversationId: id });
|
|
41
|
+
}
|
|
42
|
+
function rowOf(task) {
|
|
43
|
+
const inProgress = task.status === "in_progress";
|
|
44
|
+
return {
|
|
45
|
+
id: String(task.id),
|
|
46
|
+
title: task.subject,
|
|
47
|
+
...(task.note === undefined ? {} : { subtitle: task.note }),
|
|
48
|
+
status: {
|
|
49
|
+
label: inProgress ? (task.activeForm ?? "in progress") : task.status,
|
|
50
|
+
spinner: inProgress,
|
|
51
|
+
},
|
|
52
|
+
...(task.status === "completed" ? { tone: "neutral" } : {}),
|
|
53
|
+
...(task.blockedBy === undefined || task.blockedBy.length === 0
|
|
54
|
+
? {}
|
|
55
|
+
: { deps: task.blockedBy.map(String) }),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function dataOf(tasks) {
|
|
59
|
+
// Tombstones are not list rows and do not count toward the total.
|
|
60
|
+
const visible = tasks.filter((task) => task.status !== "deleted");
|
|
61
|
+
return {
|
|
62
|
+
done: visible.filter((task) => task.status === "completed").length,
|
|
63
|
+
total: visible.length,
|
|
64
|
+
summary: `**${visible.filter((task) => task.status === "completed").length}/${visible.length}** done`,
|
|
65
|
+
items: visible.map(rowOf),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function createTodoPanel(deps) {
|
|
69
|
+
const { host, log } = deps;
|
|
70
|
+
/** The rows + counts for one conversation, or `undefined` for `none`. */
|
|
71
|
+
async function resolve(ctx) {
|
|
72
|
+
const id = conversationIdOf(ctx.params);
|
|
73
|
+
if (id === undefined)
|
|
74
|
+
return { access: "read", data: EMPTY_DATA };
|
|
75
|
+
let meta;
|
|
76
|
+
try {
|
|
77
|
+
meta = await host.conversations.meta(id);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Unknown conversation: the id was client-supplied, so a throw is
|
|
81
|
+
// the unknown-id signal — hide the panel, never leak the error.
|
|
82
|
+
return { access: "none", data: EMPTY_DATA };
|
|
83
|
+
}
|
|
84
|
+
const access = await host.access.accessOf(ctx.principal, meta);
|
|
85
|
+
if (access === "none")
|
|
86
|
+
return { access: "none", data: EMPTY_DATA };
|
|
87
|
+
const state = await log.get(id);
|
|
88
|
+
// Tasks render in state (id) order; the spec pins the panel to it.
|
|
89
|
+
return { access: "read", data: dataOf(state.tasks) };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The panel tree, built as a PLAIN object — the same shape
|
|
93
|
+
* `~/repos/sigrun-escalations` emits. Deliberately not the host's
|
|
94
|
+
* `tree()`/`page()`/`card()` builders: those are a RUNTIME import of
|
|
95
|
+
* `@sigrun/sigrun/blocks`, which an extension installed from the npm
|
|
96
|
+
* registry cannot resolve (the host package is not a dependency of an
|
|
97
|
+
* extension — it is the host). `v` is the block-set version the
|
|
98
|
+
* server validates against (`BLOCK_SET_VERSION`, currently 1).
|
|
99
|
+
*/
|
|
100
|
+
function treeFor() {
|
|
101
|
+
const card = {
|
|
102
|
+
title: { $: "./title" },
|
|
103
|
+
subtitle: { $: "./subtitle" },
|
|
104
|
+
status: {
|
|
105
|
+
label: { $: "./status.label" },
|
|
106
|
+
spinner: { $: "./status.spinner" },
|
|
107
|
+
},
|
|
108
|
+
tone: { $: "./tone" },
|
|
109
|
+
deps: { $: "./deps" },
|
|
110
|
+
};
|
|
111
|
+
const main = [
|
|
112
|
+
{ type: "markdown", text: { $: "/summary" } },
|
|
113
|
+
{
|
|
114
|
+
type: "collection",
|
|
115
|
+
id: "todos-list",
|
|
116
|
+
display: "list",
|
|
117
|
+
items: { $: "/items" },
|
|
118
|
+
item: card,
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
return {
|
|
122
|
+
v: 1,
|
|
123
|
+
root: {
|
|
124
|
+
type: "page",
|
|
125
|
+
title: "Todos",
|
|
126
|
+
main,
|
|
127
|
+
empty: { icon: "list", title: "No tasks yet", hint: "The agent keeps this list." },
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
id: "todos",
|
|
133
|
+
title: "Todos",
|
|
134
|
+
icon: "list",
|
|
135
|
+
placement: "panel",
|
|
136
|
+
perConversation: true,
|
|
137
|
+
// NO instanceKey: the default canonical-JSON key of the channel's
|
|
138
|
+
// params is what the webui hashes for live updates.
|
|
139
|
+
async render(ctx) {
|
|
140
|
+
const { access, data } = await resolve(ctx);
|
|
141
|
+
return {
|
|
142
|
+
tree: treeFor(),
|
|
143
|
+
data: data,
|
|
144
|
+
access,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
async data(ctx) {
|
|
148
|
+
const { access, data } = await resolve(ctx);
|
|
149
|
+
return { data: data, access };
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The static prompt section: the discipline, not the list. The current list
|
|
3
|
+
* must never appear here — it changes per update, and prompt bytes that
|
|
4
|
+
* change per update break every cache the provider keeps. Freshness of the
|
|
5
|
+
* list is the hooks' job (check + reminder), not the prompt's.
|
|
6
|
+
*/
|
|
7
|
+
export declare const TODO_SECTION_TEXT = "Todo list: keep the `todo` list current as you work. Update an item as it completes or its status changes; delete an item a change of direction makes obsolete rather than leaving it open. The list belongs to the work, so maintain it as the work happens \u2014 not as a cleanup pass at the end.";
|
|
8
|
+
export declare const todoSection: import("@earendil-works/pi-durable").PromptSection<import("@earendil-works/pi-durable").ToolRegistration>;
|
package/dist/section.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The static prompt section: the discipline, not the list. The current list
|
|
3
|
+
* must never appear here — it changes per update, and prompt bytes that
|
|
4
|
+
* change per update break every cache the provider keeps. Freshness of the
|
|
5
|
+
* list is the hooks' job (check + reminder), not the prompt's.
|
|
6
|
+
*/
|
|
7
|
+
import { section } from "@earendil-works/pi-durable";
|
|
8
|
+
export const TODO_SECTION_TEXT = "Todo list: keep the `todo` list current as you work. Update an item as it completes or its status changes; delete an item a change of direction makes obsolete rather than leaving it open. The list belongs to the work, so maintain it as the work happens — not as a cleanup pass at the end.";
|
|
9
|
+
export const todoSection = section("todo", (input) => input.agent.tools.some((tool) => tool.name === "todo") ? TODO_SECTION_TEXT : undefined);
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type Static, Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Task, TaskStatus, TodoState } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reducer outcome. Closed tagged union: a new action must extend this union AND
|
|
5
|
+
* `changesState` (compiler-enforced exhaustive), so every action decides whether
|
|
6
|
+
* it persists a snapshot.
|
|
7
|
+
*
|
|
8
|
+
* `error` carries its message in-band so callers can branch on
|
|
9
|
+
* `op.kind === "error"` without a side-channel boolean.
|
|
10
|
+
*/
|
|
11
|
+
export type Op = {
|
|
12
|
+
kind: "create";
|
|
13
|
+
taskIds: number[];
|
|
14
|
+
} | {
|
|
15
|
+
kind: "update";
|
|
16
|
+
id: number;
|
|
17
|
+
fromStatus: TaskStatus;
|
|
18
|
+
toStatus: TaskStatus;
|
|
19
|
+
changed: boolean;
|
|
20
|
+
} | {
|
|
21
|
+
kind: "delete";
|
|
22
|
+
id: number;
|
|
23
|
+
subject: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: "list";
|
|
26
|
+
statusFilter?: TaskStatus;
|
|
27
|
+
} | {
|
|
28
|
+
kind: "get";
|
|
29
|
+
task: Task;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "clear";
|
|
32
|
+
count: number;
|
|
33
|
+
} | {
|
|
34
|
+
kind: "error";
|
|
35
|
+
message: string;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* True when `op` changed the task state — the calls whose post-change snapshot
|
|
39
|
+
* must be persisted for replay. Reads, failures and a no-effect `update` leave
|
|
40
|
+
* state untouched.
|
|
41
|
+
*/
|
|
42
|
+
export declare function changesState(op: Op): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The tool's parameter schema. Every `description` doubles as LLM-facing prompt
|
|
45
|
+
* copy, so the wording is part of the tool's behaviour.
|
|
46
|
+
*/
|
|
47
|
+
export declare const TodoParamsSchema: Type.TObject<{
|
|
48
|
+
action: Type.TUnsafe<"clear" | "create" | "delete" | "get" | "list" | "update">;
|
|
49
|
+
subject: Type.TOptional<Type.TString>;
|
|
50
|
+
description: Type.TOptional<Type.TString>;
|
|
51
|
+
activeForm: Type.TOptional<Type.TString>;
|
|
52
|
+
status: Type.TOptional<Type.TUnsafe<"completed" | "deleted" | "in_progress" | "pending">>;
|
|
53
|
+
blockedBy: Type.TOptional<Type.TArray<Type.TNumber>>;
|
|
54
|
+
addBlockedBy: Type.TOptional<Type.TArray<Type.TNumber>>;
|
|
55
|
+
removeBlockedBy: Type.TOptional<Type.TArray<Type.TNumber>>;
|
|
56
|
+
id: Type.TOptional<Type.TNumber>;
|
|
57
|
+
note: Type.TOptional<Type.TString>;
|
|
58
|
+
tasks: Type.TOptional<Type.TArray<Type.TObject<{
|
|
59
|
+
subject: Type.TString;
|
|
60
|
+
description: Type.TOptional<Type.TString>;
|
|
61
|
+
activeForm: Type.TOptional<Type.TString>;
|
|
62
|
+
blockedBy: Type.TOptional<Type.TArray<Type.TNumber>>;
|
|
63
|
+
}>>>;
|
|
64
|
+
}>;
|
|
65
|
+
export type TodoParams = Static<typeof TodoParamsSchema>;
|
|
66
|
+
/**
|
|
67
|
+
* Pure reducer: (state, params) → (state, op). Formatting belongs to the tool's
|
|
68
|
+
* response envelope and persistence to the log; validation stays in-reducer so
|
|
69
|
+
* every invariant is enforced before any state is written.
|
|
70
|
+
*/
|
|
71
|
+
export declare function applyTodo(state: TodoState, params: TodoParams): {
|
|
72
|
+
state: TodoState;
|
|
73
|
+
op: Op;
|
|
74
|
+
};
|