@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 ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.2] - 2026-11-05
9
+ ### Fixed
10
+ - The published 0.1.1 could not load: `src/panel.ts` imported the host's
11
+ block builders (`@sigrun/sigrun/blocks`) at RUNTIME, and an extension
12
+ installed from the registry cannot resolve the host package — it is the
13
+ host, not a dependency. The panel tree is now built as a plain object
14
+ (`{ v: 1, root: … }`) with type-only imports from `@sigrun/shared/blocks`,
15
+ matching `sigrun-escalations`. The tree is unchanged in content and
16
+ still validates against the host's `validateTree`.
17
+
18
+ ## [0.1.1] - 2026-11-04
19
+ ### Added
20
+ - The todo extension: an agent-kept task list with a read-only
21
+ per-conversation Todos panel in the webui.
22
+ - The `todo` tool (model-only): create, update, delete, clear, list, and get,
23
+ with the full post-change snapshot carried on every state-changing result
24
+ for transcript replay.
25
+ - The freshness checks: an end-of-turn check that asks the agent to account
26
+ for open items (completed, deleted with a note, or kept open with a note),
27
+ and a mid-run reminder every `reminderRounds` work rounds (default 6).
28
+ - The Todos panel: open items with an in-flight spinner and `activeForm`
29
+ label, completed items visible and greyed, and a `done/total` line.
30
+ Deleted items are tombstones — never shown, never counted.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael MacLeod
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # @sigrun/todo
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An agent-kept task list for [sigrun](https://github.com/sigrun-ai/sigrun).
4
+ The agent owns the list through its `todo` tool; the webui shows it read-only
5
+ in the conversation's right panel, updated live as the agent works.
6
+
7
+ ## Install
8
+
9
+ Add the extension to `extensions[]` in the sigrun settings file and restart:
10
+
11
+ ```json
12
+ {
13
+ "extensions": ["@sigrun/todo"]
14
+ }
15
+ ```
16
+
17
+ (a git source — `git:host/path[@ref]` — works too; the repo's committed
18
+ `dist/` is what git-source installs load.)
19
+
20
+ Until `@sigrun/todo` is published, install from a local path pointing at the
21
+ BUILT entry — `"/path/to/sigrun-todo/dist/index.js"` in `extensions[]` —
22
+ because the loader rejects a bare directory path (`Directory import … is not
23
+ supported`).
24
+
25
+ ## What you get
26
+
27
+ - **The `todo` tool** (model-only): one call per unit of work —
28
+ `create`/`update`/`delete`, `in_progress` when the agent starts an item,
29
+ `completed` when it is done.
30
+ - **The Todos panel** in the conversation's right panel: the full list in
31
+ id order, with a spinner and the agent's `activeForm` label on the item
32
+ in flight, completed items greyed (`neutral`) but visible, and a
33
+ `done/total` line.
34
+ Deleted items are tombstones — they never appear and do not count.
35
+ - **The freshness checks.** The list belongs to the work, and the extension
36
+ nudges the agent to keep it current:
37
+ - *End-of-turn check*: when a run ends having done tool rounds since it
38
+ last touched the list (or since the run started, if it never did) while
39
+ items are still open, the run continues once with a check message.
40
+ - *Mid-run reminder*: every `reminderRounds` work rounds without a `todo`
41
+ call, a one-request reminder lists the open items.
42
+ - The check always offers three honest answers — mark it completed, delete
43
+ it as obsolete, or keep it open with a note saying why; the checks never
44
+ push a model to fabricate progress.
45
+
46
+ ## Settings
47
+
48
+ `reminderRounds` — rounds between mid-run reminders (default `6`). There is
49
+ no settings section in v1: edit the extension's settings object through the
50
+ **Extensions** settings raw-JSON editor, e.g. `{ "reminderRounds": 3 }`.
51
+
52
+ ## Known limits
53
+
54
+ - The TUI does not show extension panels yet — the list is webui-only there;
55
+ the tool, section, and freshness checks work in every channel.
56
+ - The panel is read-only by design: the agent owns the list.
@@ -0,0 +1,30 @@
1
+ # Third-party notices
2
+
3
+ ## rpiv-todo
4
+
5
+ - <https://github.com/juicesharp/rpiv-todo>
6
+ - `src/types.ts`, `src/state.ts` and `src/graph.ts` are ported from
7
+ `@juicesharp/rpiv-todo` 2.15.0 (`state/state-reducer.ts`,
8
+ `state/task-graph.ts`, `state/invariants.ts`, `tool/types.ts`).
9
+
10
+ MIT License
11
+
12
+ Copyright (c) 2026 juicesharp
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Freshness analysis: a pure decision table over the request's message array.
3
+ * No I/O, no state — Task 8's hooks call it with `request.messages` and act on
4
+ * the answer.
5
+ *
6
+ * Two knobs, two different jobs:
7
+ *
8
+ * - {@link shouldCheck} — the end-of-turn check. The agent is finishing a run
9
+ * it wrote nothing to the list in; asking once is the honest nudge.
10
+ * - {@link shouldRemind} — the mid-run cadence, every `k` work rounds, so a
11
+ * long run does not drift away from the list silently.
12
+ *
13
+ * The rules are shaped around the spec's anti-goal: never nag a model into
14
+ * fabricating progress. So the unit is a ROUND (an assistant message that
15
+ * called a tool — prose is not work), a run ends at the last human turn (a new
16
+ * human message is a new intention), and {@link checkText} always offers the
17
+ * third answer: keep the item open and say why.
18
+ */
19
+ import type { Message } from "@earendil-works/pi-ai";
20
+ import type { Task, TodoState } from "./types.js";
21
+ /** The check's first token. It doubles as the "this is not a human turn" signal. */
22
+ export declare const TODO_CHECK_MARKER = "[todo check]";
23
+ /**
24
+ * What the current request's transcript says about freshness:
25
+ *
26
+ * - `offered` — the tool set this request leaves in force contains `todo`.
27
+ * A turn without the tool cannot be nagged: it has no way to answer.
28
+ * - `roundsSinceTouch` — work rounds since the later of the run start and the
29
+ * last round that called `todo`. A `todo` call in the last round is `0`.
30
+ * - `lastUserIsCheck` — the run is a continuation of our own check, so it must
31
+ * not trigger the next one.
32
+ */
33
+ export interface RunView {
34
+ offered: boolean;
35
+ roundsSinceTouch: number;
36
+ lastUserIsCheck: boolean;
37
+ }
38
+ /** Every item that still needs work, in id order. Deleted tombstones stay out. */
39
+ export declare function openItems(state: TodoState): Task[];
40
+ /**
41
+ * Read the request's messages into a {@link RunView}.
42
+ *
43
+ * The run starts after the last `user` message; with no user message at all the
44
+ * whole array is the run, which is what a multi-turn transcript with the input
45
+ * outside the list looks like.
46
+ */
47
+ export declare function analyze(messages: readonly Message[]): RunView;
48
+ /**
49
+ * Whether to ask, at the end of a run, that each open item be resolved. Skipped
50
+ * for the check's own continuation, or the check would answer itself forever.
51
+ */
52
+ export declare function shouldCheck(view: RunView, state: TodoState): boolean;
53
+ /** Whether the mid-run cadence wants its every-`k`-rounds reminder. */
54
+ export declare function shouldRemind(view: RunView, state: TodoState, k: number): boolean;
55
+ /**
56
+ * The end-of-run check, appended as an entry. It names its author because a
57
+ * model that reads it as a human request would answer the human instead of the
58
+ * list; and it spells out all three honest answers, because a check offering
59
+ * only "completed" pushes a model to close what is not done.
60
+ */
61
+ export declare function checkText(open: readonly Task[]): string;
62
+ /**
63
+ * The mid-run reminder: the count and the items, short enough to leave room for
64
+ * the work it interrupts. Reminders only fire with open items, so the list is
65
+ * never empty here.
66
+ */
67
+ export declare function reminderText(state: TodoState): string;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Freshness analysis: a pure decision table over the request's message array.
3
+ * No I/O, no state — Task 8's hooks call it with `request.messages` and act on
4
+ * the answer.
5
+ *
6
+ * Two knobs, two different jobs:
7
+ *
8
+ * - {@link shouldCheck} — the end-of-turn check. The agent is finishing a run
9
+ * it wrote nothing to the list in; asking once is the honest nudge.
10
+ * - {@link shouldRemind} — the mid-run cadence, every `k` work rounds, so a
11
+ * long run does not drift away from the list silently.
12
+ *
13
+ * The rules are shaped around the spec's anti-goal: never nag a model into
14
+ * fabricating progress. So the unit is a ROUND (an assistant message that
15
+ * called a tool — prose is not work), a run ends at the last human turn (a new
16
+ * human message is a new intention), and {@link checkText} always offers the
17
+ * third answer: keep the item open and say why.
18
+ */
19
+ import { getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
20
+ /** The check's first token. It doubles as the "this is not a human turn" signal. */
21
+ export const TODO_CHECK_MARKER = "[todo check]";
22
+ /** The tool whose calls mark a round as a "touch" of the list. */
23
+ const TODO_TOOL = "todo";
24
+ /** Every item that still needs work, in id order. Deleted tombstones stay out. */
25
+ export function openItems(state) {
26
+ return state.tasks
27
+ .filter((task) => task.status === "pending" || task.status === "in_progress")
28
+ .sort((left, right) => left.id - right.id);
29
+ }
30
+ /** Index of the last `user` message, or `-1` when the run has no human turn. */
31
+ function lastUserIndex(messages) {
32
+ for (let index = messages.length - 1; index >= 0; index--) {
33
+ if (messages[index]?.role === "user")
34
+ return index;
35
+ }
36
+ return -1;
37
+ }
38
+ /** The first text part of a human turn; `undefined` for an image-only turn. */
39
+ function firstText(content) {
40
+ if (typeof content === "string")
41
+ return content;
42
+ for (const part of content) {
43
+ if (part.type === "text")
44
+ return part.text;
45
+ }
46
+ return undefined;
47
+ }
48
+ /** A round is work: an assistant message that called at least one tool. */
49
+ function isRound(message) {
50
+ return message.role === "assistant" && message.content.some((block) => block.type === "toolCall");
51
+ }
52
+ /** Whether this round called `todo` — a read counts: it is the agent touching the list. */
53
+ function touchesList(message) {
54
+ return message.content.some((block) => block.type === "toolCall" && block.name === TODO_TOOL);
55
+ }
56
+ /** Whether the request's current tool set includes `todo`. */
57
+ function isOffered(messages) {
58
+ return getCurrentTools(messages).some((tool) => tool.name === TODO_TOOL);
59
+ }
60
+ /**
61
+ * Read the request's messages into a {@link RunView}.
62
+ *
63
+ * The run starts after the last `user` message; with no user message at all the
64
+ * whole array is the run, which is what a multi-turn transcript with the input
65
+ * outside the list looks like.
66
+ */
67
+ export function analyze(messages) {
68
+ const userIndex = lastUserIndex(messages);
69
+ const userMessage = userIndex === -1 ? undefined : messages[userIndex];
70
+ const lastUserIsCheck = userMessage?.role === "user" &&
71
+ (firstText(userMessage.content)?.startsWith(TODO_CHECK_MARKER) ?? false);
72
+ let rounds = 0;
73
+ let lastTouch = -1;
74
+ for (const message of messages.slice(userIndex + 1)) {
75
+ if (!isRound(message))
76
+ continue;
77
+ if (touchesList(message))
78
+ lastTouch = rounds;
79
+ rounds += 1;
80
+ }
81
+ return {
82
+ offered: isOffered(messages),
83
+ roundsSinceTouch: rounds - (lastTouch + 1),
84
+ lastUserIsCheck,
85
+ };
86
+ }
87
+ /**
88
+ * Whether to ask, at the end of a run, that each open item be resolved. Skipped
89
+ * for the check's own continuation, or the check would answer itself forever.
90
+ */
91
+ export function shouldCheck(view, state) {
92
+ return (view.offered &&
93
+ openItems(state).length > 0 &&
94
+ !view.lastUserIsCheck &&
95
+ view.roundsSinceTouch > 0);
96
+ }
97
+ /** Whether the mid-run cadence wants its every-`k`-rounds reminder. */
98
+ export function shouldRemind(view, state, k) {
99
+ return (view.offered &&
100
+ openItems(state).length > 0 &&
101
+ view.roundsSinceTouch > 0 &&
102
+ view.roundsSinceTouch % k === 0);
103
+ }
104
+ /** `#2 Write tests (in_progress)` — the raw status token, not prose. */
105
+ function itemLine(task) {
106
+ return `#${task.id} ${task.subject} (${task.status})`;
107
+ }
108
+ /**
109
+ * The end-of-run check, appended as an entry. It names its author because a
110
+ * model that reads it as a human request would answer the human instead of the
111
+ * list; and it spells out all three honest answers, because a check offering
112
+ * only "completed" pushes a model to close what is not done.
113
+ */
114
+ export function checkText(open) {
115
+ const items = open.map((task) => `- ${itemLine(task)}`).join("\n");
116
+ return [
117
+ `${TODO_CHECK_MARKER} This is an automated check from the \`todo\` extension, not a message from the user.`,
118
+ "",
119
+ "These items are still open:",
120
+ items,
121
+ "",
122
+ "Make one `todo` call per item: mark it completed, delete it with a note if it no longer applies, or update it with a note saying why it stays open.",
123
+ ].join("\n");
124
+ }
125
+ /**
126
+ * The mid-run reminder: the count and the items, short enough to leave room for
127
+ * the work it interrupts. Reminders only fire with open items, so the list is
128
+ * never empty here.
129
+ */
130
+ export function reminderText(state) {
131
+ const open = openItems(state);
132
+ const count = open.length === 1 ? "1 open item is" : `${open.length} open items are`;
133
+ const paragraph = `Reminder from the \`todo\` extension: ${count} still on the list. Keep it current as you work.`;
134
+ return [paragraph, "", ...open.map((task) => `- ${itemLine(task)}`)].join("\n");
135
+ }
@@ -0,0 +1,9 @@
1
+ import type { Task } from "./types.js";
2
+ /**
3
+ * Detect whether merging `newBlockedBy` into `taskId`'s `blockedBy` set would
4
+ * introduce a cycle in the dependency graph (ported from rpiv-todo, MIT).
5
+ *
6
+ * Pure of module state: takes the existing task list and the proposed additions
7
+ * explicitly so the reducer can ask "would this update cycle?" without writing.
8
+ */
9
+ export declare function detectCycle(tasks: readonly Task[], taskId: number, newBlockedBy: readonly number[]): boolean;
package/dist/graph.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Detect whether merging `newBlockedBy` into `taskId`'s `blockedBy` set would
3
+ * introduce a cycle in the dependency graph (ported from rpiv-todo, MIT).
4
+ *
5
+ * Pure of module state: takes the existing task list and the proposed additions
6
+ * explicitly so the reducer can ask "would this update cycle?" without writing.
7
+ */
8
+ export function detectCycle(tasks, taskId, newBlockedBy) {
9
+ const edges = new Map();
10
+ for (const task of tasks) {
11
+ if (task.id === taskId) {
12
+ const merged = new Set([...(task.blockedBy ?? []), ...newBlockedBy]);
13
+ edges.set(task.id, [...merged]);
14
+ }
15
+ else {
16
+ edges.set(task.id, task.blockedBy ? [...task.blockedBy] : []);
17
+ }
18
+ }
19
+ const visiting = new Set();
20
+ const visited = new Set();
21
+ const hasCycleFrom = (node) => {
22
+ if (visiting.has(node))
23
+ return true;
24
+ if (visited.has(node))
25
+ return false;
26
+ visiting.add(node);
27
+ for (const blocked of edges.get(node) ?? []) {
28
+ if (hasCycleFrom(blocked))
29
+ return true;
30
+ }
31
+ visiting.delete(node);
32
+ visited.add(node);
33
+ return false;
34
+ };
35
+ for (const node of edges.keys()) {
36
+ if (hasCycleFrom(node))
37
+ return true;
38
+ }
39
+ return false;
40
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Generation hooks: the freshness policy (Task 7) wired into a run.
3
+ *
4
+ * Three handlers, three jobs:
5
+ *
6
+ * - `afterTools` ends each round: a round that changed the list makes the
7
+ * log's cache entry dirty, and `settle` dropping it reports `true` — the
8
+ * signal to refresh whatever panel is open (`onChanged`).
9
+ * - `beforeRequest` runs before every request attempt. It analyzes the
10
+ * request's messages (the only place the current tool list exists) and
11
+ * stashes the {@link RunView} for `onYield`, and appends the mid-run
12
+ * reminder to the messages it returns — that request only, never a
13
+ * transcript entry.
14
+ * - `onYield` fires the end-of-turn check: read AND DELETE the stashed view
15
+ * (memory hygiene — one entry per conversation, dropped on every yield
16
+ * whether or not a continuation is returned, since the view is otherwise
17
+ * never re-read for that run), and when {@link shouldCheck} holds,
18
+ * continue the run with the check message.
19
+ *
20
+ * Once-per-run needs no state of its own: after the check's continuation,
21
+ * the check message is the run's last user message, so `lastUserIsCheck`
22
+ * holds for the rest of the run — even after more work rounds. `api.memo`
23
+ * must not be used instead: it is per generation task and does not survive
24
+ * the handover to the successor generation.
25
+ *
26
+ * Error isolation is pi-durable's job (a throwing hook is reported as
27
+ * `extension_error`), so these handlers carry no try/catch of their own.
28
+ */
29
+ import { type ConversationId, type HookRegistration } from "@earendil-works/pi-durable";
30
+ import type { TodoLog } from "./log.js";
31
+ export interface TodoHooksDeps {
32
+ readonly log: TodoLog;
33
+ /** The reminder cadence `k` (rounds), read per request so settings changes apply. */
34
+ readonly reminderRounds: () => Promise<number>;
35
+ /** The panel's cue that the committed list changed (after a `todo` round). */
36
+ readonly onChanged: (conversationId: ConversationId) => void;
37
+ }
38
+ export declare function createTodoHooks(deps: TodoHooksDeps): HookRegistration;
package/dist/hooks.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Generation hooks: the freshness policy (Task 7) wired into a run.
3
+ *
4
+ * Three handlers, three jobs:
5
+ *
6
+ * - `afterTools` ends each round: a round that changed the list makes the
7
+ * log's cache entry dirty, and `settle` dropping it reports `true` — the
8
+ * signal to refresh whatever panel is open (`onChanged`).
9
+ * - `beforeRequest` runs before every request attempt. It analyzes the
10
+ * request's messages (the only place the current tool list exists) and
11
+ * stashes the {@link RunView} for `onYield`, and appends the mid-run
12
+ * reminder to the messages it returns — that request only, never a
13
+ * transcript entry.
14
+ * - `onYield` fires the end-of-turn check: read AND DELETE the stashed view
15
+ * (memory hygiene — one entry per conversation, dropped on every yield
16
+ * whether or not a continuation is returned, since the view is otherwise
17
+ * never re-read for that run), and when {@link shouldCheck} holds,
18
+ * continue the run with the check message.
19
+ *
20
+ * Once-per-run needs no state of its own: after the check's continuation,
21
+ * the check message is the run's last user message, so `lastUserIsCheck`
22
+ * holds for the rest of the run — even after more work rounds. `api.memo`
23
+ * must not be used instead: it is per generation task and does not survive
24
+ * the handover to the successor generation.
25
+ *
26
+ * Error isolation is pi-durable's job (a throwing hook is reported as
27
+ * `extension_error`), so these handlers carry no try/catch of their own.
28
+ */
29
+ import { GenerationTask, hook, } from "@earendil-works/pi-durable";
30
+ import { analyze, checkText, openItems, reminderText, shouldCheck, shouldRemind, } from "./freshness.js";
31
+ export function createTodoHooks(deps) {
32
+ const { log, reminderRounds, onChanged } = deps;
33
+ // `beforeRequest` → `onYield` handoff: `onYield` has no tool list, so the
34
+ // view computed where one exists is stashed per conversation.
35
+ const views = new Map();
36
+ return hook(GenerationTask, {
37
+ afterTools(_assistant, _results, api) {
38
+ if (log.settle(api.conversationId))
39
+ onChanged(api.conversationId);
40
+ },
41
+ async beforeRequest(request, api) {
42
+ const view = analyze(request.messages);
43
+ views.set(api.conversationId, view);
44
+ const state = await log.get(api.conversationId);
45
+ if (!shouldRemind(view, state, await reminderRounds()))
46
+ return undefined;
47
+ const reminder = {
48
+ role: "user",
49
+ content: [{ type: "text", text: reminderText(state) }],
50
+ timestamp: Date.now(),
51
+ };
52
+ return { messages: [...request.messages, reminder] };
53
+ },
54
+ async onYield(_answer, api) {
55
+ const view = views.get(api.conversationId);
56
+ views.delete(api.conversationId);
57
+ if (view === undefined)
58
+ return undefined;
59
+ const state = await log.get(api.conversationId);
60
+ if (!shouldCheck(view, state))
61
+ return undefined;
62
+ return { continue: [{ type: "text", text: checkText(openItems(state)) }] };
63
+ },
64
+ });
65
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The todo extension factory (Task 9): one {@link TodoLog} in front of the
3
+ * transcript, shared by the tool, the hooks, and the panel, with one
4
+ * `onChanged` notifying the panel's canonical instance key.
5
+ *
6
+ * The factory declares ONE parameter so the loader passes the host in
7
+ * (`factory.length > 0` gates the host argument); a defaulted first
8
+ * parameter would swallow it instead.
9
+ */
10
+ import type { SigrunExtensionResult, SigrunHost } from "@sigrun/sigrun/extension";
11
+ /**
12
+ * The todo extension: an agent-kept task list with a per-conversation panel.
13
+ * The required host parameter keeps the factory at arity one for the loader.
14
+ */
15
+ export default function createTodo(host: SigrunHost): SigrunExtensionResult;
package/dist/index.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The todo extension factory (Task 9): one {@link TodoLog} in front of the
3
+ * transcript, shared by the tool, the hooks, and the panel, with one
4
+ * `onChanged` notifying the panel's canonical instance key.
5
+ *
6
+ * The factory declares ONE parameter so the loader passes the host in
7
+ * (`factory.length > 0` gates the host argument); a defaulted first
8
+ * parameter would swallow it instead.
9
+ */
10
+ import { defineExtension } from "@earendil-works/pi-durable";
11
+ import { createTodoHooks } from "./hooks.js";
12
+ import { TodoLog, hostEntryReader } from "./log.js";
13
+ import { createTodoPanel, panelKey } from "./panel.js";
14
+ import { todoSection } from "./section.js";
15
+ import { createTodoTool } from "./tool.js";
16
+ /** The default mid-run reminder cadence (rounds between reminders). */
17
+ const DEFAULT_REMINDER_ROUNDS = 6;
18
+ /**
19
+ * The todo extension: an agent-kept task list with a per-conversation panel.
20
+ * The required host parameter keeps the factory at arity one for the loader.
21
+ */
22
+ export default function createTodo(host) {
23
+ // ONE log: the tool's mutations, the hooks' freshness reads, and the
24
+ // panel's renders must see the same cache so `settle` re-reads the
25
+ // transcript exactly once per round.
26
+ const log = new TodoLog(hostEntryReader(host));
27
+ const onChanged = (id) => {
28
+ host.surfaces.changed("todos", panelKey(id));
29
+ };
30
+ const deps = { log, onChanged };
31
+ return {
32
+ extension: defineExtension({
33
+ name: "todo",
34
+ tools: [createTodoTool(deps)],
35
+ sections: [todoSection],
36
+ hooks: [
37
+ createTodoHooks({
38
+ ...deps,
39
+ // Read PER USE so an edit to the extension's settings applies
40
+ // on the next request, without a reload. A positive integer is
41
+ // the cadence; anything else is the default.
42
+ reminderRounds: async () => {
43
+ const settings = (await host.stores.settings.get());
44
+ const value = settings?.reminderRounds;
45
+ return typeof value === "number" && Number.isInteger(value) && value > 0
46
+ ? value
47
+ : DEFAULT_REMINDER_ROUNDS;
48
+ },
49
+ }),
50
+ ],
51
+ }),
52
+ surfaces: { panels: [createTodoPanel({ host, log })] },
53
+ runSettled: (event) => {
54
+ // Settle at RUN end, not round end: `afterTools` never runs on an
55
+ // aborted round, so this is the only notification path that always
56
+ // fires. A dirty entry means the round's change is in the
57
+ // transcript; a clean one means nothing changed.
58
+ if (log.settle(event.conversationId))
59
+ onChanged(event.conversationId);
60
+ },
61
+ };
62
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,77 @@
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 type { ConversationId, EntryRecord } from "@earendil-works/pi-durable";
16
+ import type { SigrunHost } from "@sigrun/sigrun/extension";
17
+ import { type Op } from "./state.js";
18
+ import { type TodoState } from "./types.js";
19
+ /**
20
+ * A fork-aware, newest-first read of one conversation's transcript. The host
21
+ * shape is {@link hostEntryReader}; tests drive arrays directly.
22
+ */
23
+ export type EntryReader = (conversationId: ConversationId) => AsyncIterable<EntryRecord>;
24
+ /**
25
+ * The {@link EntryReader} over a sigrun host's fork-aware transcript read,
26
+ * paging `host.conversations.entries` newest-first until exhausted.
27
+ */
28
+ export declare function hostEntryReader(host: Pick<SigrunHost, "conversations">): EntryReader;
29
+ /**
30
+ * The todo list of one conversation: the newest committed `todo` snapshot in
31
+ * its fork-aware history. Walks newest→oldest; a `pi.reset` entry ends the
32
+ * walk (history before the reset is not this list's history); `pi.compaction`
33
+ * and everything else is walked past. No snapshot → {@link EMPTY_STATE}.
34
+ */
35
+ export declare function replay(read: EntryReader, conversationId: ConversationId): Promise<TodoState>;
36
+ /**
37
+ * The per-conversation todo cache in front of the transcript.
38
+ *
39
+ * `get` seeds the cache from the transcript once per conversation;
40
+ * concurrent callers share ONE seeding promise (the transcript is scanned
41
+ * once no matter how many of a round's tool calls race). `mutate` awaits
42
+ * that seed, then reads-applies-writes synchronously — the NO-`await`
43
+ * window between the read and the write is what serializes parallel calls:
44
+ * no two mutations can observe the same state. A mutation whose op changes
45
+ * state ({@link changesState}) marks the entry dirty.
46
+ *
47
+ * `settle` ends a round: a dirty entry is dropped (the transcript either
48
+ * holds its snapshot by then or the call was aborted, in which case the
49
+ * uncommitted change must disappear) and reports `true`; a clean entry is
50
+ * kept and reports `false`. `forget` drops the entry unconditionally.
51
+ */
52
+ export declare class TodoLog {
53
+ #private;
54
+ constructor(read: EntryReader);
55
+ /** The conversation's current state, seeding from the transcript once. */
56
+ get(id: ConversationId): Promise<TodoState>;
57
+ /**
58
+ * Apply `f` to the conversation's current state. Serialized with every
59
+ * other `mutate` of this conversation: after the seed resolves, the
60
+ * read-apply-write below contains no `await`, so it is atomic.
61
+ */
62
+ mutate(id: ConversationId, f: (state: TodoState) => {
63
+ state: TodoState;
64
+ op: Op;
65
+ }): Promise<{
66
+ state: TodoState;
67
+ op: Op;
68
+ }>;
69
+ /**
70
+ * End the conversation's round: a dirty entry is dropped (return `true`)
71
+ * so the next `get` re-reads the transcript — the committed snapshot, or
72
+ * the state without an aborted call's uncommitted change. Clean → `false`.
73
+ */
74
+ settle(id: ConversationId): boolean;
75
+ /** Drop the conversation's cache entry; the next `get` re-seeds. */
76
+ forget(id: ConversationId): void;
77
+ }