@astralyn/pi 0.84.3 → 0.84.5

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.
Files changed (269) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli/args.d.ts +1 -0
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +11 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/auth-command.d.ts.map +1 -1
  7. package/dist/cli/auth-command.js +5 -4
  8. package/dist/cli/auth-command.js.map +1 -1
  9. package/dist/cli/project-trust.d.ts.map +1 -1
  10. package/dist/cli/project-trust.js +1 -4
  11. package/dist/cli/project-trust.js.map +1 -1
  12. package/dist/core/agent-session.d.ts +6 -1
  13. package/dist/core/agent-session.d.ts.map +1 -1
  14. package/dist/core/agent-session.js +7 -6
  15. package/dist/core/agent-session.js.map +1 -1
  16. package/dist/core/experimental.d.ts +4 -0
  17. package/dist/core/experimental.d.ts.map +1 -1
  18. package/dist/core/experimental.js +4 -0
  19. package/dist/core/experimental.js.map +1 -1
  20. package/dist/core/extensions/index.d.ts +1 -1
  21. package/dist/core/extensions/index.d.ts.map +1 -1
  22. package/dist/core/extensions/index.js.map +1 -1
  23. package/dist/core/extensions/runner.d.ts.map +1 -1
  24. package/dist/core/extensions/runner.js +4 -0
  25. package/dist/core/extensions/runner.js.map +1 -1
  26. package/dist/core/extensions/types.d.ts +10 -14
  27. package/dist/core/extensions/types.d.ts.map +1 -1
  28. package/dist/core/extensions/types.js.map +1 -1
  29. package/dist/core/keybindings.d.ts +29 -0
  30. package/dist/core/keybindings.d.ts.map +1 -1
  31. package/dist/core/keybindings.js +4 -0
  32. package/dist/core/keybindings.js.map +1 -1
  33. package/dist/core/model-config.d.ts +5 -0
  34. package/dist/core/model-config.d.ts.map +1 -1
  35. package/dist/core/model-config.js +1 -0
  36. package/dist/core/model-config.js.map +1 -1
  37. package/dist/core/model-registry.d.ts +2 -0
  38. package/dist/core/model-registry.d.ts.map +1 -1
  39. package/dist/core/model-registry.js +4 -0
  40. package/dist/core/model-registry.js.map +1 -1
  41. package/dist/core/project-trust.js +2 -2
  42. package/dist/core/project-trust.js.map +1 -1
  43. package/dist/core/sdk.d.ts +5 -3
  44. package/dist/core/sdk.d.ts.map +1 -1
  45. package/dist/core/sdk.js +2 -1
  46. package/dist/core/sdk.js.map +1 -1
  47. package/dist/core/session-manager.js +2 -2
  48. package/dist/core/session-manager.js.map +1 -1
  49. package/dist/core/settings-manager.d.ts +6 -0
  50. package/dist/core/settings-manager.d.ts.map +1 -1
  51. package/dist/core/settings-manager.js +12 -0
  52. package/dist/core/settings-manager.js.map +1 -1
  53. package/dist/core/system-prompt.js +1 -1
  54. package/dist/core/system-prompt.js.map +1 -1
  55. package/dist/core/tools/bash.d.ts +2 -1
  56. package/dist/core/tools/bash.d.ts.map +1 -1
  57. package/dist/core/tools/bash.js +3 -1
  58. package/dist/core/tools/bash.js.map +1 -1
  59. package/dist/core/tools/edit.d.ts.map +1 -1
  60. package/dist/core/tools/edit.js +2 -0
  61. package/dist/core/tools/edit.js.map +1 -1
  62. package/dist/core/tools/find.js +1 -1
  63. package/dist/core/tools/find.js.map +1 -1
  64. package/dist/core/tools/grep.js +1 -1
  65. package/dist/core/tools/grep.js.map +1 -1
  66. package/dist/core/tools/read.d.ts.map +1 -1
  67. package/dist/core/tools/read.js +2 -0
  68. package/dist/core/tools/read.js.map +1 -1
  69. package/dist/core/tools/write.d.ts.map +1 -1
  70. package/dist/core/tools/write.js +2 -0
  71. package/dist/core/tools/write.js.map +1 -1
  72. package/dist/extensions/background/index.d.ts +74 -0
  73. package/dist/extensions/background/index.d.ts.map +1 -0
  74. package/dist/extensions/background/index.js +332 -0
  75. package/dist/extensions/background/index.js.map +1 -0
  76. package/dist/extensions/background/manager.d.ts +81 -0
  77. package/dist/extensions/background/manager.d.ts.map +1 -0
  78. package/dist/extensions/background/manager.js +320 -0
  79. package/dist/extensions/background/manager.js.map +1 -0
  80. package/dist/extensions/background/registry.d.ts +138 -0
  81. package/dist/extensions/background/registry.d.ts.map +1 -0
  82. package/dist/extensions/background/registry.js +358 -0
  83. package/dist/extensions/background/registry.js.map +1 -0
  84. package/dist/extensions/background/render.d.ts +26 -0
  85. package/dist/extensions/background/render.d.ts.map +1 -0
  86. package/dist/extensions/background/render.js +118 -0
  87. package/dist/extensions/background/render.js.map +1 -0
  88. package/dist/extensions/index.d.ts.map +1 -1
  89. package/dist/extensions/index.js +2 -0
  90. package/dist/extensions/index.js.map +1 -1
  91. package/dist/extensions/subagent/activity.d.ts +0 -1
  92. package/dist/extensions/subagent/activity.d.ts.map +1 -1
  93. package/dist/extensions/subagent/activity.js +2 -5
  94. package/dist/extensions/subagent/activity.js.map +1 -1
  95. package/dist/extensions/subagent/agents-command.d.ts +4 -0
  96. package/dist/extensions/subagent/agents-command.d.ts.map +1 -0
  97. package/dist/extensions/subagent/agents-command.js +385 -0
  98. package/dist/extensions/subagent/agents-command.js.map +1 -0
  99. package/dist/extensions/subagent/agents.d.ts +3 -6
  100. package/dist/extensions/subagent/agents.d.ts.map +1 -1
  101. package/dist/extensions/subagent/agents.js +22 -190
  102. package/dist/extensions/subagent/agents.js.map +1 -1
  103. package/dist/extensions/subagent/constants.d.ts +4 -5
  104. package/dist/extensions/subagent/constants.d.ts.map +1 -1
  105. package/dist/extensions/subagent/constants.js +11 -7
  106. package/dist/extensions/subagent/constants.js.map +1 -1
  107. package/dist/extensions/subagent/index.d.ts +0 -3
  108. package/dist/extensions/subagent/index.d.ts.map +1 -1
  109. package/dist/extensions/subagent/index.js +58 -315
  110. package/dist/extensions/subagent/index.js.map +1 -1
  111. package/dist/extensions/subagent/render.d.ts +1 -1
  112. package/dist/extensions/subagent/render.d.ts.map +1 -1
  113. package/dist/extensions/subagent/render.js +208 -585
  114. package/dist/extensions/subagent/render.js.map +1 -1
  115. package/dist/extensions/subagent/resolve.d.ts +2 -2
  116. package/dist/extensions/subagent/resolve.d.ts.map +1 -1
  117. package/dist/extensions/subagent/resolve.js +23 -15
  118. package/dist/extensions/subagent/resolve.js.map +1 -1
  119. package/dist/extensions/subagent/runner.d.ts +1 -2
  120. package/dist/extensions/subagent/runner.d.ts.map +1 -1
  121. package/dist/extensions/subagent/runner.js +109 -131
  122. package/dist/extensions/subagent/runner.js.map +1 -1
  123. package/dist/extensions/subagent/schema.d.ts +4 -10
  124. package/dist/extensions/subagent/schema.d.ts.map +1 -1
  125. package/dist/extensions/subagent/schema.js +8 -13
  126. package/dist/extensions/subagent/schema.js.map +1 -1
  127. package/dist/extensions/subagent/sdk-runner.d.ts.map +1 -1
  128. package/dist/extensions/subagent/sdk-runner.js +9 -20
  129. package/dist/extensions/subagent/sdk-runner.js.map +1 -1
  130. package/dist/extensions/subagent/settings.d.ts +2 -3
  131. package/dist/extensions/subagent/settings.d.ts.map +1 -1
  132. package/dist/extensions/subagent/settings.js +30 -21
  133. package/dist/extensions/subagent/settings.js.map +1 -1
  134. package/dist/extensions/subagent/text.d.ts +3 -0
  135. package/dist/extensions/subagent/text.d.ts.map +1 -0
  136. package/dist/extensions/subagent/text.js +16 -0
  137. package/dist/extensions/subagent/text.js.map +1 -0
  138. package/dist/extensions/subagent/types.d.ts +13 -33
  139. package/dist/extensions/subagent/types.d.ts.map +1 -1
  140. package/dist/extensions/subagent/types.js.map +1 -1
  141. package/dist/extensions/subagent/ui/model-list.d.ts +68 -0
  142. package/dist/extensions/subagent/ui/model-list.d.ts.map +1 -0
  143. package/dist/extensions/subagent/ui/model-list.js +248 -0
  144. package/dist/extensions/subagent/ui/model-list.js.map +1 -0
  145. package/dist/extensions/todo/constants.d.ts +6 -17
  146. package/dist/extensions/todo/constants.d.ts.map +1 -1
  147. package/dist/extensions/todo/constants.js +20 -31
  148. package/dist/extensions/todo/constants.js.map +1 -1
  149. package/dist/extensions/todo/index.d.ts.map +1 -1
  150. package/dist/extensions/todo/index.js +36 -57
  151. package/dist/extensions/todo/index.js.map +1 -1
  152. package/dist/extensions/todo/schema.d.ts +20 -59
  153. package/dist/extensions/todo/schema.d.ts.map +1 -1
  154. package/dist/extensions/todo/schema.js +23 -69
  155. package/dist/extensions/todo/schema.js.map +1 -1
  156. package/dist/extensions/todo/state.d.ts +23 -65
  157. package/dist/extensions/todo/state.d.ts.map +1 -1
  158. package/dist/extensions/todo/state.js +238 -1262
  159. package/dist/extensions/todo/state.js.map +1 -1
  160. package/dist/extensions/todo/view.d.ts +19 -10
  161. package/dist/extensions/todo/view.d.ts.map +1 -1
  162. package/dist/extensions/todo/view.js +411 -463
  163. package/dist/extensions/todo/view.js.map +1 -1
  164. package/dist/extensions/todo/widget.d.ts +15 -0
  165. package/dist/extensions/todo/widget.d.ts.map +1 -0
  166. package/dist/extensions/todo/widget.js +50 -0
  167. package/dist/extensions/todo/widget.js.map +1 -0
  168. package/dist/index.d.ts +1 -1
  169. package/dist/index.d.ts.map +1 -1
  170. package/dist/index.js.map +1 -1
  171. package/dist/main.d.ts.map +1 -1
  172. package/dist/main.js +5 -1
  173. package/dist/main.js.map +1 -1
  174. package/dist/modes/interactive/components/extension-selector.d.ts +1 -2
  175. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  176. package/dist/modes/interactive/components/extension-selector.js +5 -11
  177. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  178. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  179. package/dist/modes/interactive/components/model-selector.js +2 -1
  180. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  181. package/dist/modes/interactive/components/settings-selector.d.ts +3 -1
  182. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  183. package/dist/modes/interactive/components/settings-selector.js +10 -0
  184. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  185. package/dist/modes/interactive/interactive-mode.d.ts +8 -2
  186. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  187. package/dist/modes/interactive/interactive-mode.js +77 -25
  188. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  189. package/dist/modes/interactive/model-catalog-refresh.d.ts +7 -0
  190. package/dist/modes/interactive/model-catalog-refresh.d.ts.map +1 -0
  191. package/dist/modes/interactive/model-catalog-refresh.js +36 -0
  192. package/dist/modes/interactive/model-catalog-refresh.js.map +1 -0
  193. package/dist/modes/interactive/theme/dark.json +2 -0
  194. package/dist/modes/interactive/theme/light.json +2 -0
  195. package/dist/modes/interactive/theme/theme-controller.d.ts +10 -2
  196. package/dist/modes/interactive/theme/theme-controller.d.ts.map +1 -1
  197. package/dist/modes/interactive/theme/theme-controller.js +23 -10
  198. package/dist/modes/interactive/theme/theme-controller.js.map +1 -1
  199. package/dist/modes/interactive/theme/theme-schema.json +9 -1
  200. package/dist/modes/interactive/theme/theme.d.ts +5 -3
  201. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  202. package/dist/modes/interactive/theme/theme.js +12 -2
  203. package/dist/modes/interactive/theme/theme.js.map +1 -1
  204. package/dist/modes/json-event.d.ts +4 -1
  205. package/dist/modes/json-event.d.ts.map +1 -1
  206. package/dist/modes/json-event.js +5 -2
  207. package/dist/modes/json-event.js.map +1 -1
  208. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  209. package/dist/modes/rpc/rpc-mode.js +1 -8
  210. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  211. package/dist/package-manager-cli.js +1 -1
  212. package/dist/package-manager-cli.js.map +1 -1
  213. package/dist/server/create-harness.d.ts.map +1 -1
  214. package/dist/server/create-harness.js +2 -0
  215. package/dist/server/create-harness.js.map +1 -1
  216. package/dist/utils/tools-manager.d.ts +10 -1
  217. package/dist/utils/tools-manager.d.ts.map +1 -1
  218. package/dist/utils/tools-manager.js +14 -19
  219. package/dist/utils/tools-manager.js.map +1 -1
  220. package/docs/bundled/README.md +2 -1
  221. package/docs/bundled/extensions/background.md +64 -0
  222. package/docs/bundled/extensions/subagent.md +84 -57
  223. package/docs/bundled/extensions/todo.md +84 -24
  224. package/docs/bundled/tool-presentation.md +1 -1
  225. package/docs/compaction.md +6 -6
  226. package/docs/custom-provider.md +1 -1
  227. package/docs/docs.json +4 -0
  228. package/docs/environment-variables.md +8 -2
  229. package/docs/extensions.md +6 -2
  230. package/docs/json.md +6 -4
  231. package/docs/keybindings.md +9 -2
  232. package/docs/rpc.md +15 -4
  233. package/docs/sdk.md +9 -0
  234. package/docs/settings.md +31 -0
  235. package/docs/terminal-setup.md +23 -2
  236. package/docs/themes.md +24 -3
  237. package/docs/tui.md +2 -2
  238. package/docs/usage.md +4 -3
  239. package/examples/README.md +1 -1
  240. package/examples/extensions/README.md +0 -1
  241. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  242. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  243. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  244. package/examples/extensions/gondolin/package-lock.json +2 -2
  245. package/examples/extensions/gondolin/package.json +1 -1
  246. package/examples/extensions/sandbox/package-lock.json +2 -2
  247. package/examples/extensions/sandbox/package.json +1 -1
  248. package/examples/extensions/with-deps/package-lock.json +2 -2
  249. package/examples/extensions/with-deps/package.json +1 -1
  250. package/npm-shrinkwrap.json +170 -221
  251. package/package.json +6 -6
  252. package/dist/extensions/subagent/picker.d.ts +0 -31
  253. package/dist/extensions/subagent/picker.d.ts.map +0 -1
  254. package/dist/extensions/subagent/picker.js +0 -109
  255. package/dist/extensions/subagent/picker.js.map +0 -1
  256. package/dist/extensions/todo/overlay.d.ts +0 -24
  257. package/dist/extensions/todo/overlay.d.ts.map +0 -1
  258. package/dist/extensions/todo/overlay.js +0 -135
  259. package/dist/extensions/todo/overlay.js.map +0 -1
  260. package/examples/extensions/subagent/README.md +0 -175
  261. package/examples/extensions/subagent/agents/planner.md +0 -37
  262. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  263. package/examples/extensions/subagent/agents/scout.md +0 -50
  264. package/examples/extensions/subagent/agents/worker.md +0 -24
  265. package/examples/extensions/subagent/agents.ts +0 -126
  266. package/examples/extensions/subagent/index.ts +0 -1009
  267. package/examples/extensions/subagent/prompts/implement-and-review.md +0 -10
  268. package/examples/extensions/subagent/prompts/implement.md +0 -10
  269. package/examples/extensions/subagent/prompts/scout-and-plan.md +0 -9
@@ -1,62 +1,23 @@
1
- import { LIST_DISPLAY_MAX_ITEMS, TODO_MAX_ACTIVE_FORM_LENGTH, TODO_MAX_BATCH_ITEMS, TODO_MAX_BATCH_KEY_LENGTH, TODO_MAX_BLOCKED_BY, TODO_MAX_DESCRIPTION_LENGTH, TODO_MAX_LIST_LIMIT, TODO_MAX_LIST_QUERY_LENGTH, TODO_MAX_METADATA_BYTES, TODO_MAX_METADATA_DEPTH, TODO_MAX_METADATA_ENTRIES, TODO_MAX_METADATA_KEY_LENGTH, TODO_MAX_OWNER_LENGTH, TODO_MAX_SUBJECT_LENGTH, TODO_TOOL_NAME, } from "./constants.js";
1
+ /**
2
+ * todo/state.ts — v2 pure state core: closure-scoped store, atomic actions,
3
+ * snapshot cloning, and branch replay. No session registry, metadata,
4
+ * dependency graph, tombstones, filters, or legacy normalizers.
5
+ */
6
+ import { TODO_MAX_BATCH_ITEMS, TODO_MAX_DESCRIPTION_LENGTH, TODO_MAX_ITEMS, TODO_MAX_SUBJECT_LENGTH, TODO_TOOL_NAME, } from "./constants.js";
2
7
  import { EMPTY_TODO_STATE, TODO_DETAILS_SCHEMA_VERSION, } from "./schema.js";
3
- /** Subjects/descriptions that count as a verification step (CC-style soft nudge). */
4
- const VERIFICATION_PATTERN = /verif|test|check|review/i;
5
- const TODO_STATUSES = new Set(["pending", "in_progress", "completed", "deleted"]);
6
- const TODO_ACTIONS = new Set([
7
- "create",
8
- "create_many",
9
- "update",
10
- "list",
11
- "get",
12
- "delete",
13
- "clear",
14
- ]);
15
- const LEGACY_METADATA_LIMITS = {
16
- maxDepth: 64,
17
- maxEntries: 100_000,
18
- maxBytes: 16 * 1024 * 1024,
8
+ const TODO_STATUSES = new Set(["pending", "in_progress", "completed"]);
9
+ const TODO_ACTIONS = new Set(["create", "update", "list", "delete"]);
10
+ /** Parameters each action accepts; anything else is rejected with guidance. */
11
+ const ACTION_PARAMS = {
12
+ create: new Set(["items"]),
13
+ update: new Set(["id", "subject", "description", "status"]),
14
+ list: new Set([]),
15
+ delete: new Set(["ids"]),
19
16
  };
20
- // ---- per-session state ------------------------------------------------------
21
- // Keyed by session id: one Pi process can host more than one session over its
22
- // lifetime (resume, /tree branch switches), and a module-level singleton would
23
- // leak one session's list into another. Tool renderers get no ctx, so the
24
- // active session is a module-level pointer kept current by execute and the
25
- // lifecycle handlers (which do have ctx).
26
- const states = new Map();
27
- let activeSid = "";
28
- /** Point the module at a session's state bucket. Call wherever ctx is in hand. */
29
- export function setActiveTodoSession(sid) {
30
- activeSid = sid;
31
- }
32
- /** Snapshot of the active session's state; mutate via applyTodoMutation + commitTodoState. */
33
- export function getTodoState() {
34
- return cloneState(states.get(activeSid) ?? EMPTY_TODO_STATE);
35
- }
36
- export function replaceTodoState(next) {
37
- states.set(activeSid, cloneState(next));
38
- }
39
- export function commitTodoState(next) {
40
- replaceTodoState(next);
41
- }
42
- /** Drop in-memory state for a session (call from session_shutdown). */
43
- export function disposeTodoSession(sid) {
44
- if (!sid)
45
- return;
46
- states.delete(sid);
47
- if (activeSid === sid)
48
- activeSid = "";
49
- }
50
17
  function isRecord(value) {
51
18
  return value !== null && typeof value === "object" && !Array.isArray(value);
52
19
  }
53
- function isPlainRecord(value) {
54
- if (!isRecord(value))
55
- return false;
56
- const prototype = Object.getPrototypeOf(value);
57
- return prototype === Object.prototype || prototype === null;
58
- }
59
- function isPositiveInteger(value) {
20
+ function isPositiveSafeInteger(value) {
60
21
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
61
22
  }
62
23
  function isTodoStatus(value) {
@@ -65,243 +26,31 @@ function isTodoStatus(value) {
65
26
  function isTodoAction(value) {
66
27
  return typeof value === "string" && TODO_ACTIONS.has(value);
67
28
  }
68
- function jsonStringUtf8Bytes(value) {
69
- let bytes = 2; // JSON quotes
70
- for (let index = 0; index < value.length; index++) {
71
- const code = value.charCodeAt(index);
72
- if (code === 0x22 ||
73
- code === 0x5c ||
74
- code === 0x08 ||
75
- code === 0x0c ||
76
- code === 0x0a ||
77
- code === 0x0d ||
78
- code === 0x09) {
79
- bytes += 2;
80
- continue;
81
- }
82
- if (code <= 0x1f) {
83
- bytes += 6;
84
- continue;
85
- }
86
- if (code >= 0xd800 && code <= 0xdbff) {
87
- const next = value.charCodeAt(index + 1);
88
- if (next >= 0xdc00 && next <= 0xdfff) {
89
- bytes += 4;
90
- index++;
91
- }
92
- else {
93
- bytes += 6; // JSON escapes lone surrogates.
94
- }
95
- continue;
96
- }
97
- if (code >= 0xdc00 && code <= 0xdfff) {
98
- bytes += 6;
99
- continue;
100
- }
101
- bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;
102
- }
103
- return bytes;
104
- }
105
- function addMetadataBytes(stats, amount, limits) {
106
- stats.bytes += amount;
107
- if (stats.bytes > limits.maxBytes)
108
- throw new Error(`metadata exceeds ${limits.maxBytes} UTF-8 bytes`);
109
- }
110
- function addMetadataEntry(stats, limits) {
111
- stats.entries++;
112
- if (stats.entries > limits.maxEntries)
113
- throw new Error(`metadata exceeds ${limits.maxEntries} entries`);
114
- }
115
- /**
116
- * Validate and clone JSON-like values without using JSON.stringify as the
117
- * validator. This rejects cycles, accessors, non-finite numbers, functions,
118
- * symbols, bigint, undefined, and non-plain object instances.
119
- */
120
- function cloneJsonValue(value, depth, ancestors, stats, limits) {
121
- if (depth > limits.maxDepth)
122
- throw new Error(`metadata exceeds depth ${limits.maxDepth}`);
123
- if (value === null) {
124
- addMetadataBytes(stats, 4, limits);
125
- return null;
126
- }
127
- if (typeof value === "string") {
128
- addMetadataBytes(stats, jsonStringUtf8Bytes(value), limits);
129
- return value;
130
- }
131
- if (typeof value === "boolean") {
132
- addMetadataBytes(stats, value ? 4 : 5, limits);
133
- return value;
134
- }
135
- if (typeof value === "number") {
136
- if (!Number.isFinite(value))
137
- throw new Error("metadata numbers must be finite");
138
- addMetadataBytes(stats, Buffer.byteLength(String(value), "utf8"), limits);
139
- return value;
140
- }
141
- if (typeof value !== "object")
142
- throw new Error("metadata must contain only JSON-like values");
143
- if (ancestors.has(value))
144
- throw new Error("metadata cannot contain cycles");
145
- ancestors.add(value);
146
- try {
147
- if (Array.isArray(value)) {
148
- if (value.length > limits.maxEntries - stats.entries) {
149
- throw new Error(`metadata exceeds ${limits.maxEntries} entries`);
150
- }
151
- const ownKeys = Object.keys(value);
152
- if (ownKeys.length !== value.length)
153
- throw new Error("metadata arrays cannot be sparse or have extra properties");
154
- const copy = [];
155
- addMetadataBytes(stats, 2, limits);
156
- for (let index = 0; index < value.length; index++) {
157
- addMetadataEntry(stats, limits);
158
- if (index > 0)
159
- addMetadataBytes(stats, 1, limits);
160
- const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
161
- if (!descriptor || !("value" in descriptor))
162
- throw new Error("metadata cannot contain accessors");
163
- copy.push(cloneJsonValue(descriptor.value, depth + 1, ancestors, stats, limits));
164
- }
165
- return copy;
166
- }
167
- if (!isPlainRecord(value))
168
- throw new Error("metadata objects must be plain objects");
169
- const copy = {};
170
- const keys = Object.keys(value);
171
- if (keys.length > limits.maxEntries - stats.entries) {
172
- throw new Error(`metadata exceeds ${limits.maxEntries} entries`);
173
- }
174
- addMetadataBytes(stats, 2, limits);
175
- for (let index = 0; index < keys.length; index++) {
176
- const key = keys[index];
177
- if (limits.maxKeyLength !== undefined && key.length > limits.maxKeyLength) {
178
- throw new Error(`metadata keys exceed ${limits.maxKeyLength} characters`);
179
- }
180
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
181
- if (!descriptor || !("value" in descriptor))
182
- throw new Error("metadata cannot contain accessors");
183
- addMetadataEntry(stats, limits);
184
- if (index > 0)
185
- addMetadataBytes(stats, 1, limits);
186
- addMetadataBytes(stats, jsonStringUtf8Bytes(key) + 1, limits);
187
- Object.defineProperty(copy, key, {
188
- value: cloneJsonValue(descriptor.value, depth + 1, ancestors, stats, limits),
189
- enumerable: true,
190
- writable: true,
191
- configurable: true,
192
- });
193
- }
194
- return copy;
195
- }
196
- finally {
197
- ancestors.delete(value);
198
- }
29
+ function findItem(state, id) {
30
+ return state.items.find((item) => item.id === id);
199
31
  }
200
- function validateAndCloneMetadata(value, limits) {
201
- if (!isPlainRecord(value))
202
- return { error: "metadata must be a plain object" };
203
- try {
204
- const cloned = cloneJsonValue(value, 1, new WeakSet(), { entries: 0, bytes: 0 }, limits);
205
- if (!isPlainRecord(cloned))
206
- return { error: "metadata must be a plain object" };
207
- return { metadata: cloned };
32
+ /** De-duplicate ids preserving first-seen input order. */
33
+ function dedupeIds(ids) {
34
+ const seen = [];
35
+ for (const id of ids) {
36
+ if (!seen.includes(id))
37
+ seen.push(id);
208
38
  }
209
- catch (error) {
210
- return { error: error instanceof Error ? error.message : "metadata is invalid" };
211
- }
212
- }
213
- function cloneStoredMetadata(value) {
214
- const result = validateAndCloneMetadata(value, LEGACY_METADATA_LIMITS);
215
- return "metadata" in result ? result.metadata : undefined;
39
+ return seen;
216
40
  }
217
- export function cloneState(source) {
218
- return {
219
- items: source.items.map((item) => {
220
- const clone = { ...item };
221
- if (item.blockedBy)
222
- clone.blockedBy = [...item.blockedBy];
223
- else
224
- delete clone.blockedBy;
225
- if (item.metadata) {
226
- const metadata = cloneStoredMetadata(item.metadata);
227
- if (metadata)
228
- clone.metadata = metadata;
229
- else
230
- delete clone.metadata;
231
- }
232
- else
233
- delete clone.metadata;
234
- return clone;
235
- }),
236
- nextId: source.nextId,
237
- };
41
+ /** Trim and collapse every internal whitespace run to a single space. */
42
+ function normalizeText(value) {
43
+ return value.trim().replace(/\s+/g, " ");
238
44
  }
239
- function omitBlockedBy(item) {
240
- const next = { ...item };
241
- delete next.blockedBy;
242
- return next;
243
- }
244
- /**
245
- * Keep a tombstone while detaching its id from every dependent task. Also
246
- * reports which pending dependents ended up fully unblocked, so the tool
247
- * result can tell the model what became workable (mirrors the demote note).
248
- */
249
- function markDeleted(state, id) {
250
- const items = state.items.map((item) => {
251
- if (item.id === id)
252
- return { ...item, status: "deleted" };
253
- if (!item.blockedBy?.includes(id))
254
- return item;
255
- const blockedBy = item.blockedBy.filter((dependencyId) => dependencyId !== id);
256
- return blockedBy.length ? { ...item, blockedBy } : omitBlockedBy(item);
257
- });
258
- const nextState = { items, nextId: state.nextId };
259
- const releasedIds = state.items
260
- .filter((item) => item.id !== id && item.status === "pending" && item.blockedBy?.includes(id))
261
- .map((item) => item.id)
262
- .filter((dependentId) => {
263
- const dependent = findItem(nextState, dependentId);
264
- return dependent !== undefined && unresolvedDependencyIds(nextState, dependent).length === 0;
265
- });
266
- return { state: nextState, releasedIds };
267
- }
268
- function error(state, message) {
269
- return { state, operation: { kind: "error", message } };
270
- }
271
- // ---- per-action parameter validation ---------------------------------------
272
- // Parameters each action accepts. Anything else is rejected with guidance
273
- // instead of being silently ignored (a model that passes blockedBy on update
274
- // should learn about addBlockedBy, not lose the edit).
275
- const ACTION_PARAMS = {
276
- create: new Set(["subject", "description", "activeForm", "status", "blockedBy", "owner", "metadata"]),
277
- create_many: new Set(["items"]),
278
- update: new Set([
279
- "id",
280
- "subject",
281
- "description",
282
- "activeForm",
283
- "status",
284
- "addBlockedBy",
285
- "removeBlockedBy",
286
- "addBlocks",
287
- "removeBlocks",
288
- "owner",
289
- "metadata",
290
- ]),
291
- list: new Set(["status", "includeDeleted", "limit", "afterId", "query", "unblockedOnly"]),
292
- get: new Set(["id"]),
293
- delete: new Set(["id"]),
294
- clear: new Set(["confirm", "expectedCount"]),
295
- };
296
- const UPDATE_ONLY_EDGE_PARAMS = new Set(["addBlockedBy", "removeBlockedBy", "addBlocks", "removeBlocks"]);
297
- function inapplicableParamError(action, key) {
298
- if (action === "update" && key === "blockedBy") {
299
- return "blockedBy is create-only; use addBlockedBy/removeBlockedBy on update";
300
- }
301
- if (action === "create" && UPDATE_ONLY_EDGE_PARAMS.has(key)) {
302
- return `${key} is update-only; use blockedBy on create`;
303
- }
304
- return `${key} does not apply to action ${action}`;
45
+ function validateNormalizedText(value, label, maximum) {
46
+ if (typeof value !== "string")
47
+ throw new Error(`${label} must be a string`);
48
+ const normalized = normalizeText(value);
49
+ if (!normalized)
50
+ throw new Error(`${label} cannot be empty`);
51
+ if (normalized.length > maximum)
52
+ throw new Error(`${label} exceeds ${maximum} characters`);
53
+ return normalized;
305
54
  }
306
55
  function findInapplicableParam(params) {
307
56
  const allowed = ACTION_PARAMS[params.action];
@@ -309,1034 +58,261 @@ function findInapplicableParam(params) {
309
58
  if (key === "action" || value === undefined)
310
59
  continue;
311
60
  if (!allowed.has(key))
312
- return inapplicableParamError(params.action, key);
313
- }
314
- return undefined;
315
- }
316
- function validateBoundedString(value, label, maximum) {
317
- if (typeof value !== "string")
318
- return `${label} must be a string`;
319
- if (value.length > maximum)
320
- return `${label} exceeds ${maximum} characters`;
321
- return undefined;
322
- }
323
- function validateTaskTextFields(params, requireDescription) {
324
- if (params.subject !== undefined) {
325
- const subjectError = validateBoundedString(params.subject, "subject", TODO_MAX_SUBJECT_LENGTH);
326
- if (subjectError)
327
- return subjectError;
328
- }
329
- if (params.description !== undefined) {
330
- const descriptionError = validateBoundedString(params.description, "description", TODO_MAX_DESCRIPTION_LENGTH);
331
- if (descriptionError)
332
- return descriptionError;
333
- }
334
- if (params.activeForm !== undefined) {
335
- const activeFormError = validateBoundedString(params.activeForm, "activeForm", TODO_MAX_ACTIVE_FORM_LENGTH);
336
- if (activeFormError)
337
- return activeFormError;
338
- }
339
- if (params.owner !== undefined) {
340
- const ownerError = validateBoundedString(params.owner, "owner", TODO_MAX_OWNER_LENGTH);
341
- if (ownerError)
342
- return ownerError;
343
- }
344
- if (params.subject !== undefined && !params.subject.trim())
345
- return "subject cannot be empty";
346
- if (requireDescription && !params.description?.trim()) {
347
- return "description required for create: state what done means for this task";
61
+ return `${key} does not apply to action ${params.action}`;
348
62
  }
349
63
  return undefined;
350
64
  }
351
- function validateIdArray(value, label) {
352
- if (value === undefined)
353
- return [];
354
- if (!Array.isArray(value))
355
- return `${label} must be an array`;
356
- if (value.length > TODO_MAX_BLOCKED_BY)
357
- return `${label} exceeds ${TODO_MAX_BLOCKED_BY} ids`;
358
- const ids = [];
359
- for (const id of value) {
360
- if (!isPositiveInteger(id))
361
- return `${label} must contain positive integer ids`;
362
- if (!ids.includes(id))
363
- ids.push(id);
364
- }
365
- return ids;
65
+ /** Snapshot clone: items are plain string/number records, so a shallow copy is safe. */
66
+ export function cloneTodoState(state) {
67
+ return { items: state.items.map((item) => ({ ...item })), nextId: state.nextId };
366
68
  }
367
- function isTransitionAllowed(from, to) {
368
- // `deleted` is the only terminal status; `completed` can be reopened to
369
- // in_progress or pending so a premature completion recovers without losing
370
- // the task id and its blockedBy edges.
371
- if (from === to)
372
- return true;
373
- if (from === "deleted")
374
- return false;
375
- if (to === "deleted")
376
- return true;
377
- if (to === "completed")
378
- return from === "pending" || from === "in_progress";
379
- if (to === "pending")
380
- return from === "in_progress" || from === "completed";
381
- if (to === "in_progress")
382
- return from === "pending" || from === "completed";
383
- return false;
384
- }
385
- function findItem(state, id) {
386
- return state.items.find((item) => item.id === id);
387
- }
388
- function validateDependencies(state, deps, currentId) {
389
- for (const dep of deps) {
390
- if (dep === currentId)
391
- return `cannot block #${currentId} on itself`;
392
- const item = findItem(state, dep);
393
- if (!item)
394
- return `blockedBy: #${dep} not found`;
395
- if (item.status === "deleted")
396
- return `blockedBy: #${dep} is deleted`;
397
- }
398
- return undefined;
399
- }
400
- /** All listed dependencies must be completed before starting or closing a task. */
401
- function validateDependenciesReady(state, deps) {
402
- for (const dep of deps) {
403
- const item = findItem(state, dep);
404
- if (!item || item.status !== "completed") {
405
- return `blockedBy: complete #${dep} before starting or finishing this task`;
406
- }
407
- }
408
- return undefined;
409
- }
410
- /** IDs of dependencies that still prevent a task from starting or completing. */
411
- export function unresolvedDependencyIds(state, item) {
412
- return (item.blockedBy ?? []).filter((id) => findItem(state, id)?.status !== "completed");
413
- }
414
- /** True when every blockedBy dependency is completed (for transition and overlay checks). */
415
- export function dependenciesSatisfied(state, item) {
416
- return unresolvedDependencyIds(state, item).length === 0;
417
- }
418
- /** True when the resulting dependency graph contains a cycle. */
419
- function createsCycle(items, nextDepsById) {
420
- const depsById = new Map();
421
- for (const item of items)
422
- depsById.set(item.id, item.blockedBy ?? []);
423
- for (const [id, deps] of nextDepsById)
424
- depsById.set(id, deps);
425
- // An iterative color walk keeps replay and dependency edits safe for long,
426
- // valid chains that would overflow the JavaScript call stack recursively.
427
- const colors = new Map();
428
- const stack = [];
429
- for (const initialId of depsById.keys()) {
430
- if ((colors.get(initialId) ?? 0) !== 0)
431
- continue;
432
- colors.set(initialId, 1);
433
- stack.push({ id: initialId, nextDependencyIndex: 0 });
434
- while (stack.length) {
435
- const frame = stack[stack.length - 1];
436
- const dependencies = depsById.get(frame.id) ?? [];
437
- if (frame.nextDependencyIndex >= dependencies.length) {
438
- colors.set(frame.id, 2);
439
- stack.pop();
440
- continue;
441
- }
442
- const dependencyId = dependencies[frame.nextDependencyIndex];
443
- frame.nextDependencyIndex++;
444
- if (!depsById.has(dependencyId))
445
- continue;
446
- const color = colors.get(dependencyId) ?? 0;
447
- if (color === 1)
448
- return true;
449
- if (color === 2)
450
- continue;
451
- colors.set(dependencyId, 1);
452
- stack.push({ id: dependencyId, nextDependencyIndex: 0 });
453
- }
454
- }
455
- return false;
456
- }
457
- function validateAndNormalizeBatch(params, state) {
69
+ function applyCreate(state, params) {
458
70
  const rawItems = params.items;
459
71
  if (!Array.isArray(rawItems) || rawItems.length === 0)
460
- return "items required for create_many";
72
+ throw new Error("items required for create");
461
73
  if (rawItems.length > TODO_MAX_BATCH_ITEMS)
462
- return `items exceeds ${TODO_MAX_BATCH_ITEMS} tasks`;
463
- const allowedKeys = new Set([
464
- "key",
465
- "subject",
466
- "description",
467
- "activeForm",
468
- "blockedBy",
469
- "blockedByKeys",
470
- "owner",
471
- "metadata",
472
- ]);
473
- const batch = [];
474
- const keys = new Map();
74
+ throw new Error(`items exceeds ${TODO_MAX_BATCH_ITEMS} tasks`);
75
+ if (state.items.length + rawItems.length > TODO_MAX_ITEMS) {
76
+ throw new Error(`todo list is full (max ${TODO_MAX_ITEMS} tasks)`);
77
+ }
78
+ if (!Number.isSafeInteger(state.nextId) || state.nextId < 1)
79
+ throw new Error("next id is invalid");
80
+ if (state.nextId > Number.MAX_SAFE_INTEGER - rawItems.length)
81
+ throw new Error("next id is exhausted");
82
+ const sources = [];
475
83
  for (let index = 0; index < rawItems.length; index++) {
476
84
  const raw = rawItems[index];
477
- if (!isPlainRecord(raw))
478
- return `items[${index}] must be an object`;
479
- for (const property of Object.keys(raw)) {
480
- if (!allowedKeys.has(property))
481
- return `items[${index}].${property} does not apply to create_many`;
482
- }
483
- const taskParams = raw;
484
- if (typeof raw.subject !== "string" || !raw.subject.trim())
485
- return `items[${index}]: subject required for create_many`;
486
- const taskTextError = validateTaskTextFields(taskParams, true);
487
- if (taskTextError)
488
- return `items[${index}]: ${taskTextError}`;
489
- let key;
490
- if (raw.key !== undefined) {
491
- const keyError = validateBoundedString(raw.key, "key", TODO_MAX_BATCH_KEY_LENGTH);
492
- if (keyError)
493
- return `items[${index}]: ${keyError}`;
494
- key = raw.key.trim();
495
- if (!key)
496
- return `items[${index}]: key cannot be empty`;
497
- if (keys.has(key))
498
- return `items[${index}]: duplicate key ${key}`;
499
- keys.set(key, index);
500
- }
501
- const blockedBy = validateIdArray(raw.blockedBy, `items[${index}].blockedBy`);
502
- if (typeof blockedBy === "string")
503
- return blockedBy;
504
- const blockedByError = validateDependencies(state, blockedBy);
505
- if (blockedByError)
506
- return `items[${index}]: ${blockedByError}`;
507
- const rawBlockedByKeys = raw.blockedByKeys;
508
- if (rawBlockedByKeys !== undefined && !Array.isArray(rawBlockedByKeys)) {
509
- return `items[${index}].blockedByKeys must be an array`;
510
- }
511
- if (Array.isArray(rawBlockedByKeys) && rawBlockedByKeys.length > TODO_MAX_BLOCKED_BY) {
512
- return `items[${index}].blockedByKeys exceeds ${TODO_MAX_BLOCKED_BY} keys`;
513
- }
514
- const blockedByKeys = [];
515
- for (const reference of rawBlockedByKeys ?? []) {
516
- const referenceError = validateBoundedString(reference, "blockedByKeys", TODO_MAX_BATCH_KEY_LENGTH);
517
- if (referenceError)
518
- return `items[${index}]: ${referenceError}`;
519
- const normalizedReference = reference.trim();
520
- if (!normalizedReference)
521
- return `items[${index}]: blockedByKeys cannot contain an empty key`;
522
- if (!blockedByKeys.includes(normalizedReference))
523
- blockedByKeys.push(normalizedReference);
524
- }
525
- let metadata;
526
- if (raw.metadata !== undefined) {
527
- const metadataResult = validateAndCloneMetadata(raw.metadata, {
528
- maxDepth: TODO_MAX_METADATA_DEPTH,
529
- maxEntries: TODO_MAX_METADATA_ENTRIES,
530
- maxBytes: TODO_MAX_METADATA_BYTES,
531
- maxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,
532
- });
533
- if ("error" in metadataResult)
534
- return `items[${index}]: ${metadataResult.error}`;
535
- metadata = metadataResult.metadata;
85
+ if (!isRecord(raw))
86
+ throw new Error(`items[${index}] must be an object`);
87
+ for (const key of Object.keys(raw)) {
88
+ if (key !== "subject" && key !== "description") {
89
+ throw new Error(`items[${index}].${key} does not apply to action create`);
90
+ }
536
91
  }
537
- batch.push({
538
- ...(key ? { key } : {}),
539
- subject: raw.subject.trim(),
540
- description: raw.description,
541
- ...(raw.activeForm ? { activeForm: raw.activeForm } : {}),
542
- blockedBy,
543
- blockedByKeys,
544
- ...(raw.owner ? { owner: raw.owner } : {}),
545
- ...(metadata ? { metadata } : {}),
92
+ sources.push({
93
+ subject: validateNormalizedText(raw.subject, `items[${index}].subject`, TODO_MAX_SUBJECT_LENGTH),
94
+ description: validateNormalizedText(raw.description, `items[${index}].description`, TODO_MAX_DESCRIPTION_LENGTH),
546
95
  });
547
96
  }
548
- for (let index = 0; index < batch.length; index++) {
549
- for (const key of batch[index].blockedByKeys) {
550
- if (!keys.has(key))
551
- return `items[${index}]: blockedByKeys references missing key ${key}`;
552
- if (key === batch[index].key)
553
- return `items[${index}]: cannot block a task on itself`;
554
- }
555
- }
556
- return batch;
557
- }
558
- function createMany(state, params) {
559
- const batch = validateAndNormalizeBatch(params, state);
560
- if (typeof batch === "string")
561
- return error(state, batch);
562
- if (!Number.isSafeInteger(state.nextId) ||
563
- state.nextId < 1 ||
564
- state.nextId + batch.length - 1 > Number.MAX_SAFE_INTEGER) {
565
- return error(state, "next id is invalid or exhausted");
566
- }
567
- const idsByKey = new Map();
568
- for (let index = 0; index < batch.length; index++) {
569
- const key = batch[index].key;
570
- if (key)
571
- idsByKey.set(key, state.nextId + index);
572
- }
573
- const created = batch.map((source, index) => {
574
- const blockedBy = [...source.blockedBy];
575
- for (const key of source.blockedByKeys) {
576
- const id = idsByKey.get(key);
577
- if (id !== undefined && !blockedBy.includes(id))
578
- blockedBy.push(id);
97
+ // Atomic: everything above is validated before the new state is built.
98
+ const created = sources.map((source, index) => ({
99
+ id: state.nextId + index,
100
+ subject: source.subject,
101
+ description: source.description,
102
+ status: "pending",
103
+ }));
104
+ return { items: [...state.items, ...created], nextId: state.nextId + created.length };
105
+ }
106
+ function applyUpdate(state, params) {
107
+ const id = params.id;
108
+ if (!isPositiveSafeInteger(id))
109
+ throw new Error("id required for update");
110
+ const index = state.items.findIndex((item) => item.id === id);
111
+ if (index === -1)
112
+ throw new Error(`#${id} not found`);
113
+ if (params.subject === undefined && params.description === undefined && params.status === undefined) {
114
+ throw new Error("update requires at least one of subject, description, or status");
115
+ }
116
+ const subject = params.subject === undefined
117
+ ? undefined
118
+ : validateNormalizedText(params.subject, "subject", TODO_MAX_SUBJECT_LENGTH);
119
+ const description = params.description === undefined
120
+ ? undefined
121
+ : validateNormalizedText(params.description, "description", TODO_MAX_DESCRIPTION_LENGTH);
122
+ if (params.status !== undefined && !isTodoStatus(params.status))
123
+ throw new Error("status is invalid");
124
+ const current = state.items[index];
125
+ const updated = {
126
+ id: current.id,
127
+ subject: subject ?? current.subject,
128
+ description: description ?? current.description,
129
+ status: params.status ?? current.status,
130
+ };
131
+ // Exactly one in_progress: setting one demotes every other active task.
132
+ const items = state.items.map((item, itemIndex) => {
133
+ if (itemIndex === index)
134
+ return updated;
135
+ if (updated.status === "in_progress" && item.status === "in_progress") {
136
+ return { ...item, status: "pending" };
579
137
  }
580
- const item = {
581
- id: state.nextId + index,
582
- subject: source.subject,
583
- description: source.description,
584
- status: "pending",
585
- };
586
- if (source.activeForm)
587
- item.activeForm = source.activeForm;
588
- if (blockedBy.length)
589
- item.blockedBy = blockedBy;
590
- if (source.owner)
591
- item.owner = source.owner;
592
- if (source.metadata)
593
- item.metadata = source.metadata;
594
138
  return item;
595
139
  });
596
- const batchEdges = new Map();
597
- for (const item of created)
598
- batchEdges.set(item.id, item.blockedBy ?? []);
599
- if (createsCycle([...state.items, ...created], batchEdges))
600
- return error(state, "blockedBy would create a cycle");
601
- return {
602
- state: { items: [...state.items, ...created], nextId: state.nextId + created.length },
603
- operation: { kind: "create_many", ids: created.map((item) => item.id) },
604
- };
140
+ return { items, nextId: state.nextId };
141
+ }
142
+ function applyDelete(state, params) {
143
+ const rawIds = params.ids;
144
+ if (!Array.isArray(rawIds) || rawIds.length === 0)
145
+ throw new Error("ids required for delete");
146
+ if (rawIds.length > TODO_MAX_BATCH_ITEMS)
147
+ throw new Error(`ids exceeds ${TODO_MAX_BATCH_ITEMS} ids`);
148
+ const ids = [];
149
+ for (const raw of rawIds) {
150
+ if (!isPositiveSafeInteger(raw))
151
+ throw new Error("ids must contain positive integer ids");
152
+ ids.push(raw);
153
+ }
154
+ const idsToRemove = dedupeIds(ids);
155
+ // Atomic: every id must exist before anything is removed.
156
+ const byId = new Map(state.items.map((item) => [item.id, item]));
157
+ for (const candidate of idsToRemove) {
158
+ if (!byId.has(candidate))
159
+ throw new Error(`#${candidate} not found`);
160
+ }
161
+ const remove = new Set(idsToRemove);
162
+ return { items: state.items.filter((item) => !remove.has(item.id)), nextId: state.nextId };
605
163
  }
606
- export function applyTodoMutation(input, params) {
607
- const state = cloneState(input);
164
+ /**
165
+ * Pure action application: validates every runtime input and inapplicable
166
+ * parameter (tool args can be tampered after schema validation), throws on
167
+ * any invalid call, and never mutates the input state.
168
+ */
169
+ export function applyTodoAction(state, params) {
170
+ if (!isRecord(params))
171
+ throw new Error("todo params must be an object");
608
172
  if (!isTodoAction(params.action))
609
- return error(state, `unknown action: ${String(params.action)}`);
173
+ throw new Error(`unknown todo action: ${String(params.action)}`);
610
174
  const inapplicable = findInapplicableParam(params);
611
175
  if (inapplicable)
612
- return error(state, inapplicable);
176
+ throw new Error(inapplicable);
177
+ switch (params.action) {
178
+ case "create":
179
+ return applyCreate(state, params);
180
+ case "update":
181
+ return applyUpdate(state, params);
182
+ case "list":
183
+ return cloneTodoState(state);
184
+ case "delete":
185
+ return applyDelete(state, params);
186
+ }
187
+ }
188
+ /** Derive the operation model for one call from the before/after states. */
189
+ export function buildTodoChange(params, before, after) {
613
190
  switch (params.action) {
614
191
  case "create": {
615
- if (typeof params.subject !== "string" || !params.subject.trim())
616
- return error(state, "subject required for create");
617
- const taskTextError = validateTaskTextFields(params, true);
618
- if (taskTextError)
619
- return error(state, taskTextError);
620
- if (params.status !== undefined && params.status !== "pending") {
621
- return error(state, "create always starts pending; use update to start or complete a task");
622
- }
623
- const blockedBy = validateIdArray(params.blockedBy, "blockedBy");
624
- if (typeof blockedBy === "string")
625
- return error(state, blockedBy);
626
- const dependencyError = validateDependencies(state, blockedBy);
627
- if (dependencyError)
628
- return error(state, dependencyError);
629
- let metadata;
630
- if (params.metadata !== undefined) {
631
- const metadataResult = validateAndCloneMetadata(params.metadata, {
632
- maxDepth: TODO_MAX_METADATA_DEPTH,
633
- maxEntries: TODO_MAX_METADATA_ENTRIES,
634
- maxBytes: TODO_MAX_METADATA_BYTES,
635
- maxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,
636
- });
637
- if ("error" in metadataResult)
638
- return error(state, metadataResult.error);
639
- metadata = metadataResult.metadata;
640
- }
641
- if (!Number.isSafeInteger(state.nextId) || state.nextId < 1 || state.nextId === Number.MAX_SAFE_INTEGER) {
642
- return error(state, "next id is invalid or exhausted");
643
- }
644
- const item = {
645
- id: state.nextId,
646
- subject: params.subject.trim(),
647
- status: "pending",
648
- description: params.description,
649
- };
650
- if (params.activeForm)
651
- item.activeForm = params.activeForm;
652
- if (blockedBy.length)
653
- item.blockedBy = blockedBy;
654
- if (params.owner)
655
- item.owner = params.owner;
656
- if (metadata)
657
- item.metadata = metadata;
658
- return {
659
- state: { items: [...state.items, item], nextId: state.nextId + 1 },
660
- operation: { kind: "create", id: item.id },
661
- };
192
+ const count = Array.isArray(params.items) ? params.items.length : 0;
193
+ return { kind: "create", ids: after.items.slice(after.items.length - count).map((item) => item.id) };
662
194
  }
663
- case "create_many":
664
- return createMany(state, params);
665
195
  case "update": {
666
- if (!isPositiveInteger(params.id))
667
- return error(state, "id required for update");
668
- const index = state.items.findIndex((item) => item.id === params.id);
669
- if (index === -1)
670
- return error(state, `#${params.id} not found`);
671
- const current = state.items[index];
672
- if (current.status === "deleted")
673
- return error(state, `#${params.id} is deleted and cannot be updated`);
674
- const taskTextError = validateTaskTextFields(params, false);
675
- if (taskTextError)
676
- return error(state, taskTextError);
677
- if (params.status !== undefined && !isTodoStatus(params.status))
678
- return error(state, "status is invalid");
679
- const addBlockedBy = validateIdArray(params.addBlockedBy, "addBlockedBy");
680
- const removeBlockedBy = validateIdArray(params.removeBlockedBy, "removeBlockedBy");
681
- const addBlocks = validateIdArray(params.addBlocks, "addBlocks");
682
- const removeBlocks = validateIdArray(params.removeBlocks, "removeBlocks");
683
- if (typeof addBlockedBy === "string")
684
- return error(state, addBlockedBy);
685
- if (typeof removeBlockedBy === "string")
686
- return error(state, removeBlockedBy);
687
- if (typeof addBlocks === "string")
688
- return error(state, addBlocks);
689
- if (typeof removeBlocks === "string")
690
- return error(state, removeBlocks);
691
- const edgeChange = addBlockedBy.length > 0 || removeBlockedBy.length > 0 || addBlocks.length > 0 || removeBlocks.length > 0;
692
- const fieldChange = params.subject !== undefined ||
693
- params.description !== undefined ||
694
- params.activeForm !== undefined ||
695
- params.owner !== undefined ||
696
- params.metadata !== undefined;
697
- if (!fieldChange && !edgeChange && params.status === undefined) {
698
- return error(state, "update requires at least one field");
699
- }
700
- if (params.status === "deleted") {
701
- if (fieldChange || edgeChange) {
702
- return error(state, "status deleted cannot be combined with other changes; apply edits first or delete in a separate call");
703
- }
704
- const deleted = markDeleted(state, current.id);
705
- return {
706
- state: deleted.state,
707
- operation: {
708
- kind: "update",
709
- id: current.id,
710
- from: current.status,
711
- to: "deleted",
712
- ...(deleted.releasedIds.length ? { releasedIds: deleted.releasedIds } : {}),
713
- },
714
- };
715
- }
716
- const nextStatus = params.status ?? current.status;
717
- if (!isTransitionAllowed(current.status, nextStatus)) {
718
- return error(state, `illegal transition ${current.status} -> ${nextStatus}`);
719
- }
720
- let metadata;
721
- if (params.metadata !== undefined) {
722
- const patchResult = validateAndCloneMetadata(params.metadata, {
723
- maxDepth: TODO_MAX_METADATA_DEPTH,
724
- maxEntries: TODO_MAX_METADATA_ENTRIES,
725
- maxBytes: TODO_MAX_METADATA_BYTES,
726
- maxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,
727
- });
728
- if ("error" in patchResult)
729
- return error(state, patchResult.error);
730
- const merged = Object.create(null);
731
- for (const [key, value] of Object.entries(current.metadata ?? {}))
732
- merged[key] = value;
733
- for (const [key, value] of Object.entries(patchResult.metadata)) {
734
- if (value === null)
735
- delete merged[key];
736
- else
737
- merged[key] = value;
738
- }
739
- if (Object.keys(merged).length) {
740
- const mergedResult = validateAndCloneMetadata(merged, {
741
- maxDepth: TODO_MAX_METADATA_DEPTH,
742
- maxEntries: TODO_MAX_METADATA_ENTRIES,
743
- maxBytes: TODO_MAX_METADATA_BYTES,
744
- maxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,
745
- });
746
- if ("error" in mergedResult)
747
- return error(state, mergedResult.error);
748
- metadata = mergedResult.metadata;
749
- }
750
- }
751
- // Collect every task whose blockedBy changes: this task (addBlockedBy /
752
- // removeBlockedBy) plus reverse-edge targets (addBlocks / removeBlocks).
753
- const nextDepsById = new Map();
754
- let nextDeps = current.blockedBy ? [...current.blockedBy] : [];
755
- if (removeBlockedBy.length) {
756
- const remove = new Set(removeBlockedBy);
757
- nextDeps = nextDeps.filter((id) => !remove.has(id));
758
- }
759
- if (addBlockedBy.length) {
760
- const dependencyError = validateDependencies(state, addBlockedBy, current.id);
761
- if (dependencyError)
762
- return error(state, dependencyError);
763
- for (const dep of addBlockedBy) {
764
- if (!nextDeps.includes(dep))
765
- nextDeps.push(dep);
766
- }
767
- }
768
- nextDepsById.set(current.id, nextDeps);
769
- if (removeBlocks.length) {
770
- const release = new Set(removeBlocks);
771
- for (const candidate of state.items) {
772
- if (!release.has(candidate.id) || !candidate.blockedBy?.includes(current.id))
773
- continue;
774
- nextDepsById.set(candidate.id, candidate.blockedBy.filter((dependencyId) => dependencyId !== current.id));
775
- }
776
- }
777
- if (addBlocks.length) {
778
- for (const targetId of addBlocks) {
779
- if (targetId === current.id)
780
- return error(state, `cannot block #${targetId} on itself`);
781
- const target = findItem(state, targetId);
782
- if (!target)
783
- return error(state, `addBlocks: #${targetId} not found`);
784
- if (target.status === "deleted")
785
- return error(state, `addBlocks: #${targetId} is deleted`);
786
- const deps = nextDepsById.get(targetId) ?? (target.blockedBy ? [...target.blockedBy] : []);
787
- if (!deps.includes(current.id))
788
- deps.push(current.id);
789
- nextDepsById.set(targetId, deps);
790
- }
791
- }
792
- if (createsCycle(state.items, nextDepsById)) {
793
- return error(state, "blockedBy would create a cycle");
794
- }
795
- // Readiness gates explicit status changes. An already active task that
796
- // takes on a new incomplete edge is instead automatically demoted below.
797
- if ((nextStatus === "in_progress" || nextStatus === "completed") && current.status !== nextStatus) {
798
- const readyError = validateDependenciesReady(state, nextDeps);
799
- if (readyError)
800
- return error(state, readyError);
801
- }
802
- const updated = { ...current, status: nextStatus };
803
- if (params.subject !== undefined)
804
- updated.subject = params.subject.trim();
805
- // Empty string removes an optional text field (metadata uses null keys).
806
- if (params.description !== undefined) {
807
- if (params.description)
808
- updated.description = params.description;
809
- else
810
- delete updated.description;
811
- }
812
- if (params.activeForm !== undefined) {
813
- if (params.activeForm)
814
- updated.activeForm = params.activeForm;
815
- else
816
- delete updated.activeForm;
817
- }
818
- if (params.owner !== undefined) {
819
- if (params.owner)
820
- updated.owner = params.owner;
821
- else
822
- delete updated.owner;
823
- }
824
- if (nextDeps.length)
825
- updated.blockedBy = nextDeps;
826
- else
827
- delete updated.blockedBy;
828
- if (params.metadata !== undefined) {
829
- if (metadata)
830
- updated.metadata = metadata;
831
- else
832
- delete updated.metadata;
833
- }
834
- const items = state.items.map((task) => {
835
- if (task.id === current.id)
836
- return updated;
837
- const deps = nextDepsById.get(task.id);
838
- if (deps === undefined)
839
- return task;
840
- return deps.length ? { ...task, blockedBy: deps } : omitBlockedBy(task);
841
- });
842
- // A task that was already active may become newly blocked through either
843
- // direction of an edge edit. Keep it out of in_progress and distinguish
844
- // this from the usual single-active-task demotion in the result.
845
- const blockedIds = [];
846
- for (const [targetId, deps] of nextDepsById) {
847
- const before = findItem(state, targetId);
848
- const targetIndex = items.findIndex((item) => item.id === targetId);
849
- if (!before || targetIndex === -1 || items[targetIndex].status !== "in_progress")
850
- continue;
851
- const previousDeps = before.blockedBy ?? [];
852
- const gainedUnresolved = deps.some((dependencyId) => {
853
- if (previousDeps.includes(dependencyId))
854
- return false;
855
- const dependency = items.find((item) => item.id === dependencyId);
856
- return dependency?.status !== "completed";
857
- });
858
- if (gainedUnresolved) {
859
- items[targetIndex] = { ...items[targetIndex], status: "pending" };
860
- blockedIds.push(targetId);
861
- }
862
- }
863
- // Exactly one in_progress: demote any other active tasks to pending.
864
- const demotedIds = [];
865
- const finalCurrent = items[index];
866
- if (nextStatus === "in_progress" && finalCurrent.status === "in_progress") {
867
- for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
868
- const item = items[itemIndex];
869
- if (item.id === finalCurrent.id || item.status !== "in_progress")
870
- continue;
871
- items[itemIndex] = { ...item, status: "pending" };
872
- demotedIds.push(item.id);
873
- }
874
- }
875
- const nextState = { items, nextId: state.nextId };
876
- const finalItem = items[index];
877
- const newlyCompleted = current.status !== "completed" && finalItem.status === "completed";
878
- return {
879
- state: nextState,
880
- operation: {
881
- kind: "update",
882
- id: finalItem.id,
883
- from: current.status,
884
- to: finalItem.status,
885
- ...(demotedIds.length ? { demotedIds } : {}),
886
- ...(blockedIds.length ? { blockedIds } : {}),
887
- ...(newlyCompleted && needsVerificationNudge(nextState) ? { verificationNudge: true } : {}),
888
- },
889
- };
890
- }
891
- case "list": {
892
- if (params.status !== undefined && !isTodoStatus(params.status))
893
- return error(state, "status is invalid");
894
- if (params.includeDeleted !== undefined && typeof params.includeDeleted !== "boolean") {
895
- return error(state, "includeDeleted must be a boolean");
896
- }
897
- if (params.unblockedOnly !== undefined && typeof params.unblockedOnly !== "boolean") {
898
- return error(state, "unblockedOnly must be a boolean");
899
- }
900
- if (params.limit !== undefined &&
901
- (!Number.isSafeInteger(params.limit) || params.limit < 1 || params.limit > TODO_MAX_LIST_LIMIT)) {
902
- return error(state, `limit must be an integer from 1 to ${TODO_MAX_LIST_LIMIT}`);
903
- }
904
- if (params.afterId !== undefined && !isPositiveInteger(params.afterId))
905
- return error(state, "afterId must be a positive integer");
906
- if (params.query !== undefined) {
907
- const queryError = validateBoundedString(params.query, "query", TODO_MAX_LIST_QUERY_LENGTH);
908
- if (queryError)
909
- return error(state, queryError);
910
- }
196
+ const id = params.id;
197
+ if (!isPositiveSafeInteger(id))
198
+ throw new Error("id required for update");
199
+ const demotedId = before.items.find((item) => item.id !== id && item.status === "in_progress" && findItem(after, item.id)?.status === "pending")?.id;
911
200
  return {
912
- state,
913
- operation: {
914
- kind: "list",
915
- status: params.status,
916
- includeDeleted: params.includeDeleted === true,
917
- limit: params.limit ?? LIST_DISPLAY_MAX_ITEMS,
918
- ...(params.afterId !== undefined ? { afterId: params.afterId } : {}),
919
- ...(params.query !== undefined ? { query: params.query } : {}),
920
- unblockedOnly: params.unblockedOnly === true,
921
- legacyOutput: params.limit === undefined &&
922
- params.afterId === undefined &&
923
- params.query === undefined &&
924
- params.unblockedOnly === undefined,
925
- },
201
+ kind: "update",
202
+ id,
203
+ from: findItem(before, id)?.status ?? "pending",
204
+ to: findItem(after, id)?.status ?? "pending",
205
+ ...(demotedId !== undefined ? { demotedId } : {}),
926
206
  };
927
207
  }
928
- case "get": {
929
- if (!isPositiveInteger(params.id))
930
- return error(state, "id required for get");
931
- const item = findItem(state, params.id);
932
- if (!item)
933
- return error(state, `#${params.id} not found`);
934
- return { state, operation: { kind: "get", item } };
935
- }
208
+ case "list":
209
+ return { kind: "list" };
936
210
  case "delete": {
937
- if (!isPositiveInteger(params.id))
938
- return error(state, "id required for delete");
939
- const index = state.items.findIndex((item) => item.id === params.id);
940
- if (index === -1)
941
- return error(state, `#${params.id} not found`);
942
- const current = state.items[index];
943
- if (current.status === "deleted")
944
- return error(state, `#${params.id} is already deleted`);
945
- const deleted = markDeleted(state, current.id);
946
- return {
947
- state: deleted.state,
948
- operation: {
949
- kind: "delete",
950
- id: current.id,
951
- subject: current.subject,
952
- ...(deleted.releasedIds.length ? { releasedIds: deleted.releasedIds } : {}),
953
- },
954
- };
211
+ const ids = Array.isArray(params.ids) ? dedupeIds(params.ids) : [];
212
+ const removed = ids
213
+ .map((id) => findItem(before, id))
214
+ .filter((item) => item !== undefined)
215
+ .map((item) => ({ id: item.id, subject: item.subject }));
216
+ return { kind: "delete", removed };
955
217
  }
956
- case "clear":
957
- if (params.confirm !== true)
958
- return error(state, "clear requires confirm: true");
959
- if (!Number.isSafeInteger(params.expectedCount) || params.expectedCount !== state.items.length) {
960
- return error(state, `clear expectedCount must equal current task count (${state.items.length})`);
961
- }
962
- return {
963
- // IDs remain monotonic across a clear so stale references cannot point
964
- // at an unrelated task created later on the same conversation branch.
965
- state: { items: [], nextId: state.nextId },
966
- operation: { kind: "clear", count: state.items.length },
967
- };
968
218
  }
969
219
  }
970
- function isTodoStatusCounts(value) {
971
- if (!isPlainRecord(value))
972
- return false;
973
- for (const [status, count] of Object.entries(value)) {
974
- if (!isTodoStatus(status) || typeof count !== "number" || !Number.isSafeInteger(count) || count < 0)
975
- return false;
976
- }
977
- return true;
220
+ export function buildTodoDetails(change, state) {
221
+ return { schemaVersion: TODO_DETAILS_SCHEMA_VERSION, change, state: cloneTodoState(state) };
978
222
  }
979
- function isOperationSummary(value, action) {
980
- if (!isPlainRecord(value) || value.kind !== action)
981
- return false;
982
- switch (action) {
983
- case "create":
984
- case "create_many":
985
- return Array.isArray(value.ids) && value.ids.every(isPositiveInteger);
986
- case "update":
987
- return isPositiveInteger(value.id) && isTodoStatus(value.status);
988
- case "list":
989
- return (typeof value.includeDeleted === "boolean" &&
990
- typeof value.unblockedOnly === "boolean" &&
991
- (value.status === undefined || isTodoStatus(value.status)) &&
992
- (value.limit === undefined ||
993
- (typeof value.limit === "number" && Number.isSafeInteger(value.limit) && value.limit >= 1)) &&
994
- (value.afterId === undefined || isPositiveInteger(value.afterId)) &&
995
- (value.query === undefined || typeof value.query === "string") &&
996
- (value.statusCounts === undefined || isTodoStatusCounts(value.statusCounts)) &&
997
- (value.resultCount === undefined ||
998
- (typeof value.resultCount === "number" &&
999
- Number.isSafeInteger(value.resultCount) &&
1000
- value.resultCount >= 0)));
1001
- case "get":
1002
- case "delete":
1003
- return isPositiveInteger(value.id);
1004
- case "clear":
1005
- return typeof value.count === "number" && Number.isSafeInteger(value.count) && value.count >= 0;
1006
- }
223
+ function requireValidState(value) {
224
+ const state = normalizeSnapshotState(value);
225
+ if (!state)
226
+ throw new Error("todo state is invalid");
227
+ return state;
1007
228
  }
1008
- function normalizeSnapshotState(itemsValue, nextIdValue) {
1009
- if (!Array.isArray(itemsValue) || !isPositiveInteger(nextIdValue))
229
+ /** Closure-scoped store: one list per store, no module-global session state. */
230
+ export function createTodoStore(initial) {
231
+ let state = requireValidState(initial ?? EMPTY_TODO_STATE);
232
+ return {
233
+ getState() {
234
+ return cloneTodoState(state);
235
+ },
236
+ replaceState(next) {
237
+ state = requireValidState(next);
238
+ },
239
+ execute(params) {
240
+ const before = state;
241
+ const after = applyTodoAction(before, params);
242
+ const details = buildTodoDetails(buildTodoChange(params, before, after), after);
243
+ state = after;
244
+ return details;
245
+ },
246
+ };
247
+ }
248
+ /** Validate an external v2 snapshot; returns undefined when malformed. */
249
+ function normalizeSnapshotState(value) {
250
+ if (!isRecord(value) || !Array.isArray(value.items) || !isPositiveSafeInteger(value.nextId))
251
+ return undefined;
252
+ if (value.items.length > TODO_MAX_ITEMS)
1010
253
  return undefined;
1011
254
  const items = [];
1012
255
  const ids = new Set();
1013
- const itemsById = new Map();
1014
- for (const rawItem of itemsValue) {
1015
- if (!isPlainRecord(rawItem) || !isPositiveInteger(rawItem.id) || ids.has(rawItem.id))
256
+ let maxId = 0;
257
+ let activeCount = 0;
258
+ for (const rawItem of value.items) {
259
+ if (!isRecord(rawItem))
1016
260
  return undefined;
1017
- if (typeof rawItem.subject !== "string" || !isTodoStatus(rawItem.status))
261
+ const id = rawItem.id;
262
+ if (!isPositiveSafeInteger(id) || ids.has(id))
1018
263
  return undefined;
1019
- if (rawItem.description !== undefined && typeof rawItem.description !== "string")
264
+ const subject = rawItem.subject;
265
+ const description = rawItem.description;
266
+ const status = rawItem.status;
267
+ if (typeof subject !== "string" || typeof description !== "string")
1020
268
  return undefined;
1021
- if (rawItem.activeForm !== undefined && typeof rawItem.activeForm !== "string")
269
+ if (subject !== normalizeText(subject) || description !== normalizeText(description))
1022
270
  return undefined;
1023
- if (rawItem.owner !== undefined && typeof rawItem.owner !== "string")
271
+ if (!subject || subject.length > TODO_MAX_SUBJECT_LENGTH)
1024
272
  return undefined;
1025
- let blockedBy;
1026
- if (rawItem.blockedBy !== undefined) {
1027
- if (!Array.isArray(rawItem.blockedBy) || !rawItem.blockedBy.every(isPositiveInteger))
1028
- return undefined;
1029
- blockedBy = Array.from(new Set(rawItem.blockedBy));
1030
- }
1031
- let metadata;
1032
- if (rawItem.metadata !== undefined) {
1033
- const metadataResult = validateAndCloneMetadata(rawItem.metadata, LEGACY_METADATA_LIMITS);
1034
- if ("error" in metadataResult)
1035
- return undefined;
1036
- metadata = metadataResult.metadata;
1037
- }
1038
- const item = { id: rawItem.id, subject: rawItem.subject, status: rawItem.status };
1039
- if (rawItem.description !== undefined)
1040
- item.description = rawItem.description;
1041
- if (rawItem.activeForm !== undefined)
1042
- item.activeForm = rawItem.activeForm;
1043
- if (blockedBy?.length)
1044
- item.blockedBy = blockedBy;
1045
- if (rawItem.owner !== undefined)
1046
- item.owner = rawItem.owner;
1047
- if (metadata)
1048
- item.metadata = metadata;
1049
- items.push(item);
1050
- itemsById.set(item.id, item);
1051
- ids.add(item.id);
1052
- }
1053
- const highestId = items.reduce((highest, item) => Math.max(highest, item.id), 0);
1054
- if (nextIdValue <= highestId)
1055
- return undefined;
1056
- for (const item of items) {
1057
- for (const dependencyId of item.blockedBy ?? []) {
1058
- const dependency = itemsById.get(dependencyId);
1059
- if (!dependency || dependency.status === "deleted")
1060
- return undefined;
1061
- }
1062
- }
1063
- if (createsCycle(items, new Map()))
273
+ if (!description || description.length > TODO_MAX_DESCRIPTION_LENGTH)
274
+ return undefined;
275
+ if (!isTodoStatus(status))
276
+ return undefined;
277
+ ids.add(id);
278
+ if (id > maxId)
279
+ maxId = id;
280
+ if (status === "in_progress")
281
+ activeCount++;
282
+ items.push({ id, subject, description, status });
283
+ }
284
+ if (activeCount > 1)
1064
285
  return undefined;
1065
- // Legacy snapshots could contain the formerly permitted "active but newly
1066
- // blocked" state. Normalize it to the current invariant rather than losing
1067
- // the newest otherwise-valid task list during replay.
1068
- for (const item of items) {
1069
- if (item.status !== "in_progress")
1070
- continue;
1071
- if ((item.blockedBy ?? []).some((id) => itemsById.get(id)?.status !== "completed")) {
1072
- item.status = "pending";
1073
- }
1074
- }
1075
- if (items.filter((item) => item.status === "in_progress").length > 1)
286
+ if (value.nextId <= maxId)
1076
287
  return undefined;
1077
- items.sort((first, second) => first.id - second.id);
1078
- return { items, nextId: nextIdValue };
1079
- }
1080
- function normalizeDetails(value) {
1081
- if (!isPlainRecord(value) || !isTodoAction(value.action) || !isPlainRecord(value.params))
1082
- return undefined;
1083
- if (value.schemaVersion !== undefined) {
1084
- if (value.schemaVersion !== TODO_DETAILS_SCHEMA_VERSION || !isOperationSummary(value.operation, value.action)) {
1085
- return undefined;
1086
- }
1087
- }
1088
- return normalizeSnapshotState(value.items, value.nextId);
288
+ return { items, nextId: value.nextId };
1089
289
  }
290
+ /**
291
+ * Replay the newest valid v2 todo snapshot from a branch, scanning tail to
292
+ * head so a malformed latest snapshot falls back to an earlier valid one.
293
+ * v1 details are ignored, and the `change` field is not validated.
294
+ */
1090
295
  export function replayTodosFromBranch(ctx) {
1091
- // Latest valid todo toolResult wins; scan tail → head so malformed latest
1092
- // snapshots cannot poison a branch that still has an earlier valid state.
1093
296
  const branch = Array.from(ctx.sessionManager.getBranch());
1094
297
  for (let index = branch.length - 1; index >= 0; index--) {
1095
298
  const entry = branch[index];
1096
- if (entry.type !== "message")
299
+ if (!isRecord(entry) || entry.type !== "message")
300
+ continue;
301
+ const message = entry.message;
302
+ if (!isRecord(message) || message.role !== "toolResult" || message.toolName !== TODO_TOOL_NAME)
1097
303
  continue;
1098
- if (entry.message?.role !== "toolResult" || entry.message.toolName !== TODO_TOOL_NAME)
304
+ const details = message.details;
305
+ if (!isRecord(details) || details.schemaVersion !== TODO_DETAILS_SCHEMA_VERSION)
1099
306
  continue;
1100
- let state;
1101
307
  try {
1102
- state = normalizeDetails(entry.message.details);
308
+ const state = normalizeSnapshotState(details.state);
309
+ if (state)
310
+ return state;
1103
311
  }
1104
312
  catch {
1105
- // Session history is external input; ignore hostile or malformed details.
1106
- continue;
313
+ // Session history is external input; keep scanning for an earlier valid v2.
1107
314
  }
1108
- if (state)
1109
- return cloneState(state);
1110
315
  }
1111
- return cloneState(EMPTY_TODO_STATE);
1112
- }
1113
- function cloneDetailsParams(params) {
1114
- const copy = {};
1115
- for (const [key, value] of Object.entries(params)) {
1116
- if (value === undefined)
1117
- continue;
1118
- if (key === "metadata") {
1119
- const metadata = validateAndCloneMetadata(value, LEGACY_METADATA_LIMITS);
1120
- if ("metadata" in metadata)
1121
- copy[key] = metadata.metadata;
1122
- continue;
1123
- }
1124
- if (key === "items" && Array.isArray(value)) {
1125
- copy[key] = value.map((item) => {
1126
- if (!isPlainRecord(item))
1127
- return item;
1128
- const itemCopy = { ...item };
1129
- if (item.metadata !== undefined) {
1130
- const metadata = validateAndCloneMetadata(item.metadata, LEGACY_METADATA_LIMITS);
1131
- if ("metadata" in metadata)
1132
- itemCopy.metadata = metadata.metadata;
1133
- }
1134
- return itemCopy;
1135
- });
1136
- continue;
1137
- }
1138
- copy[key] = value;
1139
- }
1140
- return copy;
1141
- }
1142
- function buildTodoOperationSummary(params, next) {
1143
- switch (params.action) {
1144
- case "create":
1145
- return { kind: "create", ids: [next.nextId - 1] };
1146
- case "create_many": {
1147
- const count = Array.isArray(params.items) ? params.items.length : 0;
1148
- const firstId = next.nextId - count;
1149
- return { kind: "create_many", ids: Array.from({ length: count }, (_, index) => firstId + index) };
1150
- }
1151
- case "update": {
1152
- const item = findItem(next, params.id);
1153
- return { kind: "update", id: params.id, status: item?.status ?? "pending" };
1154
- }
1155
- case "list": {
1156
- const listOperation = {
1157
- kind: "list",
1158
- ...(params.status !== undefined ? { status: params.status } : {}),
1159
- includeDeleted: params.includeDeleted === true,
1160
- limit: params.limit ?? LIST_DISPLAY_MAX_ITEMS,
1161
- ...(params.afterId !== undefined ? { afterId: params.afterId } : {}),
1162
- ...(params.query !== undefined ? { query: params.query } : {}),
1163
- unblockedOnly: params.unblockedOnly === true,
1164
- legacyOutput: false,
1165
- };
1166
- const shown = getListView(listOperation, next).slice(0, listOperation.limit);
1167
- const statusCounts = {};
1168
- for (const item of shown)
1169
- statusCounts[item.status] = (statusCounts[item.status] ?? 0) + 1;
1170
- return {
1171
- kind: "list",
1172
- ...(listOperation.status !== undefined ? { status: listOperation.status } : {}),
1173
- includeDeleted: listOperation.includeDeleted,
1174
- limit: listOperation.limit,
1175
- ...(listOperation.afterId !== undefined ? { afterId: listOperation.afterId } : {}),
1176
- ...(listOperation.query !== undefined ? { query: listOperation.query } : {}),
1177
- unblockedOnly: listOperation.unblockedOnly,
1178
- statusCounts,
1179
- resultCount: shown.length,
1180
- };
1181
- }
1182
- case "get":
1183
- return { kind: "get", id: params.id };
1184
- case "delete":
1185
- return { kind: "delete", id: params.id };
1186
- case "clear":
1187
- return { kind: "clear", count: params.expectedCount };
1188
- }
1189
- }
1190
- export function buildTodoDetails(params, next) {
1191
- const snapshot = cloneState(next);
1192
- return {
1193
- schemaVersion: TODO_DETAILS_SCHEMA_VERSION,
1194
- action: params.action,
1195
- params: cloneDetailsParams(params),
1196
- operation: buildTodoOperationSummary(params, snapshot),
1197
- items: snapshot.items,
1198
- nextId: snapshot.nextId,
1199
- };
1200
- }
1201
- const VERIFICATION_NUDGE_NOTE = "NOTE: You closed out 3+ tasks with no verification/test/review step. Before the final summary, run checks or add a verification task and complete it — do not self-declare done with known failures.";
1202
- function formatReleasedSuffix(releasedIds) {
1203
- if (!releasedIds?.length)
1204
- return "";
1205
- return `; released ${releasedIds.map((id) => `#${id}`).join(",")} (no longer blocked)`;
1206
- }
1207
- function formatDisplayText(value, maximum) {
1208
- return value.length > maximum ? `${value.slice(0, maximum)}…` : value;
1209
- }
1210
- function getListView(operation, state) {
1211
- let view = state.items;
1212
- // An explicit deleted-status query is sufficient intent to include tombstones.
1213
- if (!operation.includeDeleted && operation.status !== "deleted") {
1214
- view = view.filter((item) => item.status !== "deleted");
1215
- }
1216
- if (operation.status)
1217
- view = view.filter((item) => item.status === operation.status);
1218
- if (operation.afterId !== undefined) {
1219
- const afterId = operation.afterId;
1220
- view = view.filter((item) => item.id > afterId);
1221
- }
1222
- if (operation.query !== undefined) {
1223
- const query = operation.query.toLocaleLowerCase();
1224
- view = view.filter((item) => item.subject.toLocaleLowerCase().includes(query) || item.description?.toLocaleLowerCase().includes(query));
1225
- }
1226
- if (operation.unblockedOnly)
1227
- view = view.filter((item) => dependenciesSatisfied(state, item));
1228
- return [...view].sort((first, second) => first.id - second.id);
1229
- }
1230
- export function formatTodoContent(operation, state) {
1231
- switch (operation.kind) {
1232
- case "create": {
1233
- const item = findItem(state, operation.id);
1234
- return item
1235
- ? `Created #${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (pending)`
1236
- : `Created #${operation.id}`;
1237
- }
1238
- case "create_many": {
1239
- const noun = operation.ids.length === 1 ? "task" : "tasks";
1240
- const lines = operation.ids.map((id) => {
1241
- const item = findItem(state, id);
1242
- return item
1243
- ? `#${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (${item.status})`
1244
- : `#${id}`;
1245
- });
1246
- return `Created ${operation.ids.length} ${noun}:${lines.length ? `\n${lines.join("\n")}` : ""}`;
1247
- }
1248
- case "update": {
1249
- const transition = operation.from === operation.to ? "" : ` (${operation.from} -> ${operation.to})`;
1250
- let text = `Updated #${operation.id}${transition}`;
1251
- if (operation.demotedIds?.length) {
1252
- text += `; ${operation.demotedIds.map((id) => `#${id}`).join(",")} demoted to pending`;
1253
- }
1254
- if (operation.blockedIds?.length) {
1255
- text += `; ${operation.blockedIds.map((id) => `#${id}`).join(",")} moved to pending (dependencies incomplete)`;
1256
- }
1257
- text += formatReleasedSuffix(operation.releasedIds);
1258
- if (operation.verificationNudge)
1259
- text += `\n\n${VERIFICATION_NUDGE_NOTE}`;
1260
- return text;
1261
- }
1262
- case "delete":
1263
- return `Deleted #${operation.id}: ${formatDisplayText(operation.subject, TODO_MAX_SUBJECT_LENGTH)}${formatReleasedSuffix(operation.releasedIds)}`;
1264
- case "clear":
1265
- return `Cleared ${operation.count} tasks`;
1266
- case "list":
1267
- return formatBoundedList(getListView(operation, state), state, operation);
1268
- case "get":
1269
- return formatDetailItem(operation.item, state);
1270
- case "error":
1271
- return `Error: ${operation.message}`;
1272
- }
1273
- }
1274
- /**
1275
- * Soft nudge when the whole list is done (no active work), there are 3+ completed
1276
- * tasks, and none look like a verification/test/review step — mirrors CC TodoWrite.
1277
- */
1278
- function needsVerificationNudge(state) {
1279
- const active = state.items.filter((item) => item.status !== "deleted");
1280
- if (active.length < 3)
1281
- return false;
1282
- if (active.some((item) => item.status === "pending" || item.status === "in_progress"))
1283
- return false;
1284
- const completed = active.filter((item) => item.status === "completed");
1285
- if (completed.length < 3)
1286
- return false;
1287
- return !completed.some((item) => VERIFICATION_PATTERN.test(item.subject) ||
1288
- (item.description !== undefined && VERIFICATION_PATTERN.test(item.description)));
1289
- }
1290
- function formatBoundedList(view, state, operation) {
1291
- if (!view.length)
1292
- return "No tasks";
1293
- const shown = view.slice(0, operation.limit);
1294
- const lines = shown.map((item) => formatListItem(item, state));
1295
- const omitted = view.length - shown.length;
1296
- if (omitted > 0) {
1297
- if (!operation.legacyOutput) {
1298
- lines.push(`… and ${omitted} more; next page: afterId=${shown.at(-1)?.id}.`);
1299
- }
1300
- else {
1301
- // Do not suggest a filter that is already applied.
1302
- const hint = operation.status ? "use get with id= for details" : "narrow with status= or use get with id=";
1303
- lines.push(`… and ${omitted} more (${shown.length} of ${view.length} shown); ${hint}.`);
1304
- }
1305
- }
1306
- return lines.join("\n");
1307
- }
1308
- function formatListIdList(ids) {
1309
- const shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);
1310
- return ids.length > shown.length ? `${shown.join(",")},…+${ids.length - shown.length}` : shown.join(",");
1311
- }
1312
- function formatListItem(item, state) {
1313
- const unresolved = unresolvedDependencyIds(state, item);
1314
- const deps = unresolved.length ? ` blockedBy=${formatListIdList(unresolved)}` : "";
1315
- const owner = item.owner ? ` @${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}` : "";
1316
- const active = item.status === "in_progress" && item.activeForm
1317
- ? ` (${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)})`
1318
- : "";
1319
- return `[${item.status}] #${item.id} ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}${active}${owner}${deps}`;
1320
- }
1321
- function formatIdList(ids) {
1322
- const shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);
1323
- return ids.length > shown.length ? `${shown.join(", ")}, … +${ids.length - shown.length}` : shown.join(", ");
1324
- }
1325
- function formatDetailItem(item, state) {
1326
- const lines = [`#${item.id} [${item.status}] ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}`];
1327
- if (item.description)
1328
- lines.push(`description: ${formatDisplayText(item.description, TODO_MAX_DESCRIPTION_LENGTH)}`);
1329
- if (item.activeForm)
1330
- lines.push(`activeForm: ${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)}`);
1331
- if (item.blockedBy?.length)
1332
- lines.push(`blockedBy: ${formatIdList(item.blockedBy)}`);
1333
- const blocks = state.items
1334
- .filter((candidate) => candidate.blockedBy?.includes(item.id))
1335
- .map((candidate) => candidate.id);
1336
- if (blocks.length)
1337
- lines.push(`blocks: ${formatIdList(blocks)}`);
1338
- if (item.owner)
1339
- lines.push(`owner: ${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}`);
1340
- return lines.join("\n");
316
+ return cloneTodoState(EMPTY_TODO_STATE);
1341
317
  }
1342
318
  //# sourceMappingURL=state.js.map