@astralyn/pi 0.82.4 → 0.82.6

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 (144) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/cli/project-trust.d.ts.map +1 -1
  3. package/dist/cli/project-trust.js +4 -1
  4. package/dist/cli/project-trust.js.map +1 -1
  5. package/dist/core/agent-session.d.ts.map +1 -1
  6. package/dist/core/agent-session.js +2 -2
  7. package/dist/core/agent-session.js.map +1 -1
  8. package/dist/core/bash-executor.d.ts.map +1 -1
  9. package/dist/core/bash-executor.js +4 -22
  10. package/dist/core/bash-executor.js.map +1 -1
  11. package/dist/core/extensions/index.d.ts +2 -2
  12. package/dist/core/extensions/index.d.ts.map +1 -1
  13. package/dist/core/extensions/index.js +1 -1
  14. package/dist/core/extensions/index.js.map +1 -1
  15. package/dist/core/extensions/loader.d.ts +1 -1
  16. package/dist/core/extensions/loader.d.ts.map +1 -1
  17. package/dist/core/extensions/loader.js +2 -3
  18. package/dist/core/extensions/loader.js.map +1 -1
  19. package/dist/core/extensions/types.d.ts +37 -5
  20. package/dist/core/extensions/types.d.ts.map +1 -1
  21. package/dist/core/extensions/types.js +11 -0
  22. package/dist/core/extensions/types.js.map +1 -1
  23. package/dist/core/tools/output-accumulator.d.ts +3 -13
  24. package/dist/core/tools/output-accumulator.d.ts.map +1 -1
  25. package/dist/core/tools/output-accumulator.js +6 -36
  26. package/dist/core/tools/output-accumulator.js.map +1 -1
  27. package/dist/extensions/plan/constants.d.ts +11 -2
  28. package/dist/extensions/plan/constants.d.ts.map +1 -1
  29. package/dist/extensions/plan/constants.js +14 -2
  30. package/dist/extensions/plan/constants.js.map +1 -1
  31. package/dist/extensions/plan/index.d.ts +0 -18
  32. package/dist/extensions/plan/index.d.ts.map +1 -1
  33. package/dist/extensions/plan/index.js +35 -3
  34. package/dist/extensions/plan/index.js.map +1 -1
  35. package/dist/extensions/plan/view.d.ts +36 -0
  36. package/dist/extensions/plan/view.d.ts.map +1 -0
  37. package/dist/extensions/plan/view.js +74 -0
  38. package/dist/extensions/plan/view.js.map +1 -0
  39. package/dist/extensions/question/dialog.d.ts +3 -1
  40. package/dist/extensions/question/dialog.d.ts.map +1 -1
  41. package/dist/extensions/question/dialog.js +106 -52
  42. package/dist/extensions/question/dialog.js.map +1 -1
  43. package/dist/extensions/question/index.d.ts.map +1 -1
  44. package/dist/extensions/question/index.js +2 -2
  45. package/dist/extensions/question/index.js.map +1 -1
  46. package/dist/extensions/question/render-cache.d.ts +9 -0
  47. package/dist/extensions/question/render-cache.d.ts.map +1 -1
  48. package/dist/extensions/question/render-cache.js +22 -0
  49. package/dist/extensions/question/render-cache.js.map +1 -1
  50. package/dist/extensions/question/results.d.ts.map +1 -1
  51. package/dist/extensions/question/results.js +6 -1
  52. package/dist/extensions/question/results.js.map +1 -1
  53. package/dist/extensions/question/schema.d.ts +4 -0
  54. package/dist/extensions/question/schema.d.ts.map +1 -1
  55. package/dist/extensions/question/schema.js +19 -28
  56. package/dist/extensions/question/schema.js.map +1 -1
  57. package/dist/extensions/question/types.d.ts +1 -1
  58. package/dist/extensions/question/types.d.ts.map +1 -1
  59. package/dist/extensions/question/types.js.map +1 -1
  60. package/dist/extensions/router/stream.d.ts.map +1 -1
  61. package/dist/extensions/router/stream.js +44 -0
  62. package/dist/extensions/router/stream.js.map +1 -1
  63. package/dist/extensions/subagent/activity.d.ts.map +1 -1
  64. package/dist/extensions/subagent/activity.js +25 -4
  65. package/dist/extensions/subagent/activity.js.map +1 -1
  66. package/dist/extensions/subagent/agents.d.ts.map +1 -1
  67. package/dist/extensions/subagent/agents.js +12 -6
  68. package/dist/extensions/subagent/agents.js.map +1 -1
  69. package/dist/extensions/subagent/constants.d.ts +1 -1
  70. package/dist/extensions/subagent/constants.d.ts.map +1 -1
  71. package/dist/extensions/subagent/constants.js +3 -2
  72. package/dist/extensions/subagent/constants.js.map +1 -1
  73. package/dist/extensions/subagent/index.d.ts.map +1 -1
  74. package/dist/extensions/subagent/index.js +14 -0
  75. package/dist/extensions/subagent/index.js.map +1 -1
  76. package/dist/extensions/subagent/render.d.ts.map +1 -1
  77. package/dist/extensions/subagent/render.js +243 -116
  78. package/dist/extensions/subagent/render.js.map +1 -1
  79. package/dist/extensions/subagent/resolve.d.ts.map +1 -1
  80. package/dist/extensions/subagent/resolve.js +13 -9
  81. package/dist/extensions/subagent/resolve.js.map +1 -1
  82. package/dist/extensions/subagent/runner.d.ts +1 -1
  83. package/dist/extensions/subagent/runner.d.ts.map +1 -1
  84. package/dist/extensions/subagent/runner.js +9 -3
  85. package/dist/extensions/subagent/runner.js.map +1 -1
  86. package/dist/extensions/subagent/schema.d.ts.map +1 -1
  87. package/dist/extensions/subagent/schema.js +4 -4
  88. package/dist/extensions/subagent/schema.js.map +1 -1
  89. package/dist/extensions/subagent/sdk-runner.d.ts.map +1 -1
  90. package/dist/extensions/subagent/sdk-runner.js +5 -0
  91. package/dist/extensions/subagent/sdk-runner.js.map +1 -1
  92. package/dist/extensions/subagent/types.d.ts +7 -0
  93. package/dist/extensions/subagent/types.d.ts.map +1 -1
  94. package/dist/extensions/subagent/types.js.map +1 -1
  95. package/dist/extensions/todo/constants.d.ts +17 -6
  96. package/dist/extensions/todo/constants.d.ts.map +1 -1
  97. package/dist/extensions/todo/constants.js +47 -9
  98. package/dist/extensions/todo/constants.js.map +1 -1
  99. package/dist/extensions/todo/index.d.ts +1 -15
  100. package/dist/extensions/todo/index.d.ts.map +1 -1
  101. package/dist/extensions/todo/index.js +35 -4
  102. package/dist/extensions/todo/index.js.map +1 -1
  103. package/dist/extensions/todo/schema.d.ts +59 -7
  104. package/dist/extensions/todo/schema.d.ts.map +1 -1
  105. package/dist/extensions/todo/schema.js +72 -12
  106. package/dist/extensions/todo/schema.js.map +1 -1
  107. package/dist/extensions/todo/state.d.ts +20 -5
  108. package/dist/extensions/todo/state.d.ts.map +1 -1
  109. package/dist/extensions/todo/state.js +1016 -134
  110. package/dist/extensions/todo/state.js.map +1 -1
  111. package/dist/extensions/todo/view.d.ts +16 -1
  112. package/dist/extensions/todo/view.d.ts.map +1 -1
  113. package/dist/extensions/todo/view.js +293 -7
  114. package/dist/extensions/todo/view.js.map +1 -1
  115. package/dist/modes/interactive/components/extension-selector.d.ts +6 -1
  116. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  117. package/dist/modes/interactive/components/extension-selector.js +14 -6
  118. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  119. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  120. package/dist/modes/interactive/components/tool-execution.js +6 -0
  121. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  122. package/dist/modes/interactive/interactive-mode.d.ts +2 -0
  123. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  124. package/dist/modes/interactive/interactive-mode.js +41 -2
  125. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  126. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  127. package/dist/modes/rpc/rpc-mode.js +8 -1
  128. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  129. package/dist/utils/coalescing-terminal.d.ts +71 -17
  130. package/dist/utils/coalescing-terminal.d.ts.map +1 -1
  131. package/dist/utils/coalescing-terminal.js +136 -31
  132. package/dist/utils/coalescing-terminal.js.map +1 -1
  133. package/docs/bundled/extensions/plan.md +6 -4
  134. package/docs/bundled/extensions/question.md +9 -7
  135. package/docs/bundled/extensions/subagent.md +4 -4
  136. package/docs/bundled/extensions/todo.md +9 -5
  137. package/docs/bundled/tool-presentation.md +1 -1
  138. package/docs/extensions.md +14 -0
  139. package/npm-shrinkwrap.json +2 -2
  140. package/package.json +1 -1
  141. package/dist/utils/output-decoder.d.ts +0 -52
  142. package/dist/utils/output-decoder.d.ts.map +0 -1
  143. package/dist/utils/output-decoder.js +0 -134
  144. package/dist/utils/output-decoder.js.map +0 -1
@@ -1,7 +1,22 @@
1
- import { LIST_DISPLAY_MAX_ITEMS, TODO_TOOL_NAME } from "./constants.js";
2
- import { EMPTY_TODO_STATE, } from "./schema.js";
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";
2
+ import { EMPTY_TODO_STATE, TODO_DETAILS_SCHEMA_VERSION, } from "./schema.js";
3
3
  /** Subjects/descriptions that count as a verification step (CC-style soft nudge). */
4
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,
19
+ };
5
20
  // ---- per-session state ------------------------------------------------------
6
21
  // Keyed by session id like rewind's engine state: one Pi process can host more
7
22
  // than one session over its lifetime (resume, /tree branch switches), and a
@@ -14,8 +29,9 @@ let activeSid = "";
14
29
  export function setActiveTodoSession(sid) {
15
30
  activeSid = sid;
16
31
  }
32
+ /** Snapshot of the active session's state; mutate via applyTodoMutation + commitTodoState. */
17
33
  export function getTodoState() {
18
- return states.get(activeSid) ?? cloneState(EMPTY_TODO_STATE);
34
+ return cloneState(states.get(activeSid) ?? EMPTY_TODO_STATE);
19
35
  }
20
36
  export function replaceTodoState(next) {
21
37
  states.set(activeSid, cloneState(next));
@@ -31,6 +47,173 @@ export function disposeTodoSession(sid) {
31
47
  if (activeSid === sid)
32
48
  activeSid = "";
33
49
  }
50
+ function isRecord(value) {
51
+ return value !== null && typeof value === "object" && !Array.isArray(value);
52
+ }
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) {
60
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
61
+ }
62
+ function isTodoStatus(value) {
63
+ return typeof value === "string" && TODO_STATUSES.has(value);
64
+ }
65
+ function isTodoAction(value) {
66
+ return typeof value === "string" && TODO_ACTIONS.has(value);
67
+ }
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
+ }
199
+ }
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 };
208
+ }
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;
216
+ }
34
217
  export function cloneState(source) {
35
218
  return {
36
219
  items: source.items.map((item) => {
@@ -39,8 +222,13 @@ export function cloneState(source) {
39
222
  clone.blockedBy = [...item.blockedBy];
40
223
  else
41
224
  delete clone.blockedBy;
42
- if (item.metadata)
43
- clone.metadata = { ...item.metadata };
225
+ if (item.metadata) {
226
+ const metadata = cloneStoredMetadata(item.metadata);
227
+ if (metadata)
228
+ clone.metadata = metadata;
229
+ else
230
+ delete clone.metadata;
231
+ }
44
232
  else
45
233
  delete clone.metadata;
46
234
  return clone;
@@ -53,7 +241,11 @@ function omitBlockedBy(item) {
53
241
  delete next.blockedBy;
54
242
  return next;
55
243
  }
56
- /** Keep a tombstone while detaching its id from every dependent task. */
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
+ */
57
249
  function markDeleted(state, id) {
58
250
  const items = state.items.map((item) => {
59
251
  if (item.id === id)
@@ -63,11 +255,115 @@ function markDeleted(state, id) {
63
255
  const blockedBy = item.blockedBy.filter((dependencyId) => dependencyId !== id);
64
256
  return blockedBy.length ? { ...item, blockedBy } : omitBlockedBy(item);
65
257
  });
66
- return { items, nextId: state.nextId };
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 };
67
267
  }
68
268
  function error(state, message) {
69
269
  return { state, operation: { kind: "error", message } };
70
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}`;
305
+ }
306
+ function findInapplicableParam(params) {
307
+ const allowed = ACTION_PARAMS[params.action];
308
+ for (const [key, value] of Object.entries(params)) {
309
+ if (key === "action" || value === undefined)
310
+ continue;
311
+ 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";
348
+ }
349
+ return undefined;
350
+ }
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;
366
+ }
71
367
  function isTransitionAllowed(from, to) {
72
368
  // `deleted` is the only terminal status; `completed` can be reopened to
73
369
  // in_progress or pending so a premature completion recovers without losing
@@ -90,8 +386,6 @@ function findItem(state, id) {
90
386
  return state.items.find((item) => item.id === id);
91
387
  }
92
388
  function validateDependencies(state, deps, currentId) {
93
- if (!deps?.length)
94
- return undefined;
95
389
  for (const dep of deps) {
96
390
  if (dep === currentId)
97
391
  return `cannot block #${currentId} on itself`;
@@ -105,8 +399,6 @@ function validateDependencies(state, deps, currentId) {
105
399
  }
106
400
  /** All listed dependencies must be completed before starting or closing a task. */
107
401
  function validateDependenciesReady(state, deps) {
108
- if (!deps?.length)
109
- return undefined;
110
402
  for (const dep of deps) {
111
403
  const item = findItem(state, dep);
112
404
  if (!item || item.status !== "completed") {
@@ -123,64 +415,255 @@ export function unresolvedDependencyIds(state, item) {
123
415
  export function dependenciesSatisfied(state, item) {
124
416
  return unresolvedDependencyIds(state, item).length === 0;
125
417
  }
126
- function createsCycle(items, id, nextDeps) {
418
+ /** True when the resulting dependency graph contains a cycle. */
419
+ function createsCycle(items, nextDepsById) {
127
420
  const depsById = new Map();
128
421
  for (const item of items)
129
422
  depsById.set(item.id, item.blockedBy ?? []);
130
- depsById.set(id, nextDeps);
131
- const seen = new Set();
132
- const stack = new Set();
133
- function visit(node) {
134
- if (stack.has(node))
135
- return true;
136
- if (seen.has(node))
137
- return false;
138
- seen.add(node);
139
- stack.add(node);
140
- for (const dep of depsById.get(node) ?? []) {
141
- if (visit(dep))
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)
142
448
  return true;
449
+ if (color === 2)
450
+ continue;
451
+ colors.set(dependencyId, 1);
452
+ stack.push({ id: dependencyId, nextDependencyIndex: 0 });
143
453
  }
144
- stack.delete(node);
145
- return false;
146
454
  }
147
- return visit(id);
455
+ return false;
456
+ }
457
+ function validateAndNormalizeBatch(params, state) {
458
+ const rawItems = params.items;
459
+ if (!Array.isArray(rawItems) || rawItems.length === 0)
460
+ return "items required for create_many";
461
+ 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();
475
+ for (let index = 0; index < rawItems.length; index++) {
476
+ 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;
536
+ }
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 } : {}),
546
+ });
547
+ }
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);
579
+ }
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
+ return item;
595
+ });
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
+ };
148
605
  }
149
606
  export function applyTodoMutation(input, params) {
150
607
  const state = cloneState(input);
608
+ if (!isTodoAction(params.action))
609
+ return error(state, `unknown action: ${String(params.action)}`);
610
+ const inapplicable = findInapplicableParam(params);
611
+ if (inapplicable)
612
+ return error(state, inapplicable);
151
613
  switch (params.action) {
152
614
  case "create": {
153
- const subject = params.subject?.trim();
154
- if (!subject)
615
+ if (typeof params.subject !== "string" || !params.subject.trim())
155
616
  return error(state, "subject required for create");
617
+ const taskTextError = validateTaskTextFields(params, true);
618
+ if (taskTextError)
619
+ return error(state, taskTextError);
156
620
  if (params.status !== undefined && params.status !== "pending") {
157
621
  return error(state, "create always starts pending; use update to start or complete a task");
158
622
  }
159
- const dependencyError = validateDependencies(state, params.blockedBy);
623
+ const blockedBy = validateIdArray(params.blockedBy, "blockedBy");
624
+ if (typeof blockedBy === "string")
625
+ return error(state, blockedBy);
626
+ const dependencyError = validateDependencies(state, blockedBy);
160
627
  if (dependencyError)
161
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
+ }
162
644
  const item = {
163
645
  id: state.nextId,
164
- subject,
646
+ subject: params.subject.trim(),
165
647
  status: "pending",
648
+ description: params.description,
166
649
  };
167
- if (params.description)
168
- item.description = params.description;
169
650
  if (params.activeForm)
170
651
  item.activeForm = params.activeForm;
171
- if (params.blockedBy?.length)
172
- item.blockedBy = Array.from(new Set(params.blockedBy));
652
+ if (blockedBy.length)
653
+ item.blockedBy = blockedBy;
173
654
  if (params.owner)
174
655
  item.owner = params.owner;
175
- if (params.metadata)
176
- item.metadata = { ...params.metadata };
656
+ if (metadata)
657
+ item.metadata = metadata;
177
658
  return {
178
659
  state: { items: [...state.items, item], nextId: state.nextId + 1 },
179
660
  operation: { kind: "create", id: item.id },
180
661
  };
181
662
  }
663
+ case "create_many":
664
+ return createMany(state, params);
182
665
  case "update": {
183
- if (params.id === undefined)
666
+ if (!isPositiveInteger(params.id))
184
667
  return error(state, "id required for update");
185
668
  const index = state.items.findIndex((item) => item.id === params.id);
186
669
  if (index === -1)
@@ -188,47 +671,129 @@ export function applyTodoMutation(input, params) {
188
671
  const current = state.items[index];
189
672
  if (current.status === "deleted")
190
673
  return error(state, `#${params.id} is deleted and cannot be updated`);
191
- if (params.subject !== undefined && !params.subject.trim())
192
- return error(state, "subject cannot be empty");
193
- const hasChange = params.subject !== undefined ||
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 ||
194
693
  params.description !== undefined ||
195
694
  params.activeForm !== undefined ||
196
- params.status !== undefined ||
197
695
  params.owner !== undefined ||
198
- params.metadata !== undefined ||
199
- (params.addBlockedBy?.length ?? 0) > 0 ||
200
- (params.removeBlockedBy?.length ?? 0) > 0;
201
- if (!hasChange)
696
+ params.metadata !== undefined;
697
+ if (!fieldChange && !edgeChange && params.status === undefined) {
202
698
  return error(state, "update requires at least one field");
203
- // `status: deleted` is the update form of delete; keep its dependency
204
- // cleanup identical to the dedicated delete action.
699
+ }
205
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);
206
705
  return {
207
- state: markDeleted(state, current.id),
208
- operation: { kind: "update", id: current.id, from: current.status, to: "deleted" },
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
+ },
209
714
  };
210
715
  }
211
716
  const nextStatus = params.status ?? current.status;
212
717
  if (!isTransitionAllowed(current.status, nextStatus)) {
213
718
  return error(state, `illegal transition ${current.status} -> ${nextStatus}`);
214
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();
215
754
  let nextDeps = current.blockedBy ? [...current.blockedBy] : [];
216
- if (params.removeBlockedBy?.length) {
217
- const remove = new Set(params.removeBlockedBy);
755
+ if (removeBlockedBy.length) {
756
+ const remove = new Set(removeBlockedBy);
218
757
  nextDeps = nextDeps.filter((id) => !remove.has(id));
219
758
  }
220
- if (params.addBlockedBy?.length) {
221
- const dependencyError = validateDependencies(state, params.addBlockedBy, current.id);
759
+ if (addBlockedBy.length) {
760
+ const dependencyError = validateDependencies(state, addBlockedBy, current.id);
222
761
  if (dependencyError)
223
762
  return error(state, dependencyError);
224
- for (const dep of params.addBlockedBy) {
763
+ for (const dep of addBlockedBy) {
225
764
  if (!nextDeps.includes(dep))
226
765
  nextDeps.push(dep);
227
766
  }
228
767
  }
229
- if (createsCycle(state.items, current.id, nextDeps)) {
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)) {
230
793
  return error(state, "blockedBy would create a cycle");
231
794
  }
795
+ // Readiness gates explicit status changes. An already active task that
796
+ // takes on a new incomplete edge is instead automatically demoted below.
232
797
  if ((nextStatus === "in_progress" || nextStatus === "completed") && current.status !== nextStatus) {
233
798
  const readyError = validateDependenciesReady(state, nextDeps);
234
799
  if (readyError)
@@ -237,69 +802,131 @@ export function applyTodoMutation(input, params) {
237
802
  const updated = { ...current, status: nextStatus };
238
803
  if (params.subject !== undefined)
239
804
  updated.subject = params.subject.trim();
240
- if (params.description !== undefined)
241
- updated.description = params.description;
242
- if (params.activeForm !== undefined)
243
- updated.activeForm = params.activeForm;
244
- if (params.owner !== undefined)
245
- updated.owner = params.owner;
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
+ }
246
824
  if (nextDeps.length)
247
825
  updated.blockedBy = nextDeps;
248
826
  else
249
827
  delete updated.blockedBy;
250
828
  if (params.metadata !== undefined) {
251
- const merged = { ...(current.metadata ?? {}) };
252
- for (const [key, value] of Object.entries(params.metadata)) {
253
- if (value === null)
254
- delete merged[key];
255
- else
256
- merged[key] = value;
257
- }
258
- if (Object.keys(merged).length)
259
- updated.metadata = merged;
829
+ if (metadata)
830
+ updated.metadata = metadata;
260
831
  else
261
832
  delete updated.metadata;
262
833
  }
263
- const items = [...state.items];
264
- items[index] = updated;
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
+ }
265
863
  // Exactly one in_progress: demote any other active tasks to pending.
266
864
  const demotedIds = [];
267
- if (nextStatus === "in_progress") {
268
- for (let i = 0; i < items.length; i++) {
269
- const item = items[i];
270
- if (item.id === updated.id)
271
- continue;
272
- if (item.status !== "in_progress")
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")
273
870
  continue;
274
- items[i] = { ...item, status: "pending" };
871
+ items[itemIndex] = { ...item, status: "pending" };
275
872
  demotedIds.push(item.id);
276
873
  }
277
874
  }
278
875
  const nextState = { items, nextId: state.nextId };
279
- const newlyCompleted = current.status !== "completed" && nextStatus === "completed";
876
+ const finalItem = items[index];
877
+ const newlyCompleted = current.status !== "completed" && finalItem.status === "completed";
280
878
  return {
281
879
  state: nextState,
282
880
  operation: {
283
881
  kind: "update",
284
- id: updated.id,
882
+ id: finalItem.id,
285
883
  from: current.status,
286
- to: updated.status,
884
+ to: finalItem.status,
287
885
  ...(demotedIds.length ? { demotedIds } : {}),
886
+ ...(blockedIds.length ? { blockedIds } : {}),
288
887
  ...(newlyCompleted && needsVerificationNudge(nextState) ? { verificationNudge: true } : {}),
289
888
  },
290
889
  };
291
890
  }
292
- case "list":
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
+ }
293
911
  return {
294
912
  state,
295
913
  operation: {
296
914
  kind: "list",
297
915
  status: params.status,
298
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,
299
925
  },
300
926
  };
927
+ }
301
928
  case "get": {
302
- if (params.id === undefined)
929
+ if (!isPositiveInteger(params.id))
303
930
  return error(state, "id required for get");
304
931
  const item = findItem(state, params.id);
305
932
  if (!item)
@@ -307,7 +934,7 @@ export function applyTodoMutation(input, params) {
307
934
  return { state, operation: { kind: "get", item } };
308
935
  }
309
936
  case "delete": {
310
- if (params.id === undefined)
937
+ if (!isPositiveInteger(params.id))
311
938
  return error(state, "id required for delete");
312
939
  const index = state.items.findIndex((item) => item.id === params.id);
313
940
  if (index === -1)
@@ -315,12 +942,23 @@ export function applyTodoMutation(input, params) {
315
942
  const current = state.items[index];
316
943
  if (current.status === "deleted")
317
944
  return error(state, `#${params.id} is already deleted`);
945
+ const deleted = markDeleted(state, current.id);
318
946
  return {
319
- state: markDeleted(state, current.id),
320
- operation: { kind: "delete", id: current.id, subject: current.subject },
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
+ },
321
954
  };
322
955
  }
323
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
+ }
324
962
  return {
325
963
  // IDs remain monotonic across a clear so stale references cannot point
326
964
  // at an unrelated task created later on the same conversation branch.
@@ -328,71 +966,297 @@ export function applyTodoMutation(input, params) {
328
966
  operation: { kind: "clear", count: state.items.length },
329
967
  };
330
968
  }
331
- return error(state, `unknown action: ${params.action}`);
332
969
  }
333
- function isTodoDetails(value) {
334
- if (!value || typeof value !== "object")
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;
978
+ }
979
+ function isOperationSummary(value, action) {
980
+ if (!isPlainRecord(value) || value.kind !== action)
335
981
  return false;
336
- const record = value;
337
- return Array.isArray(record.items) && typeof record.nextId === "number";
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
+ }
1007
+ }
1008
+ function normalizeSnapshotState(itemsValue, nextIdValue) {
1009
+ if (!Array.isArray(itemsValue) || !isPositiveInteger(nextIdValue))
1010
+ return undefined;
1011
+ const items = [];
1012
+ 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))
1016
+ return undefined;
1017
+ if (typeof rawItem.subject !== "string" || !isTodoStatus(rawItem.status))
1018
+ return undefined;
1019
+ if (rawItem.description !== undefined && typeof rawItem.description !== "string")
1020
+ return undefined;
1021
+ if (rawItem.activeForm !== undefined && typeof rawItem.activeForm !== "string")
1022
+ return undefined;
1023
+ if (rawItem.owner !== undefined && typeof rawItem.owner !== "string")
1024
+ 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()))
1064
+ 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)
1076
+ 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);
338
1089
  }
339
1090
  export function replayTodosFromBranch(ctx) {
340
- // Latest todo toolResult wins; scan tail → head so long branches stop early.
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.
341
1093
  const branch = Array.from(ctx.sessionManager.getBranch());
342
- for (let i = branch.length - 1; i >= 0; i--) {
343
- const item = branch[i];
344
- if (item.type !== "message")
1094
+ for (let index = branch.length - 1; index >= 0; index--) {
1095
+ const entry = branch[index];
1096
+ if (entry.type !== "message")
345
1097
  continue;
346
- if (item.message?.role !== "toolResult" || item.message.toolName !== TODO_TOOL_NAME)
1098
+ if (entry.message?.role !== "toolResult" || entry.message.toolName !== TODO_TOOL_NAME)
347
1099
  continue;
348
- if (!isTodoDetails(item.message.details))
1100
+ let state;
1101
+ try {
1102
+ state = normalizeDetails(entry.message.details);
1103
+ }
1104
+ catch {
1105
+ // Session history is external input; ignore hostile or malformed details.
349
1106
  continue;
350
- return cloneState({
351
- items: item.message.details.items,
352
- nextId: item.message.details.nextId,
353
- });
1107
+ }
1108
+ if (state)
1109
+ return cloneState(state);
354
1110
  }
355
1111
  return cloneState(EMPTY_TODO_STATE);
356
1112
  }
357
- export function buildTodoDetails(params, next, operation) {
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);
358
1192
  return {
1193
+ schemaVersion: TODO_DETAILS_SCHEMA_VERSION,
359
1194
  action: params.action,
360
- params: params,
361
- items: cloneState(next).items,
362
- nextId: next.nextId,
363
- ...(operation.kind === "error" ? { error: operation.message } : {}),
1195
+ params: cloneDetailsParams(params),
1196
+ operation: buildTodoOperationSummary(params, snapshot),
1197
+ items: snapshot.items,
1198
+ nextId: snapshot.nextId,
364
1199
  };
365
1200
  }
366
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
+ }
367
1230
  export function formatTodoContent(operation, state) {
368
1231
  switch (operation.kind) {
369
1232
  case "create": {
370
1233
  const item = findItem(state, operation.id);
371
- return item ? `Created #${item.id}: ${item.subject} (pending)` : `Created #${operation.id}`;
1234
+ return item
1235
+ ? `Created #${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (pending)`
1236
+ : `Created #${operation.id}`;
372
1237
  }
1238
+ case "create_many":
1239
+ return `Created ${operation.ids.length} tasks: ${operation.ids.map((id) => `#${id}`).join(", ")}`;
373
1240
  case "update": {
374
1241
  const transition = operation.from === operation.to ? "" : ` (${operation.from} -> ${operation.to})`;
375
1242
  let text = `Updated #${operation.id}${transition}`;
376
1243
  if (operation.demotedIds?.length) {
377
1244
  text += `; ${operation.demotedIds.map((id) => `#${id}`).join(",")} demoted to pending`;
378
1245
  }
1246
+ if (operation.blockedIds?.length) {
1247
+ text += `; ${operation.blockedIds.map((id) => `#${id}`).join(",")} moved to pending (dependencies incomplete)`;
1248
+ }
1249
+ text += formatReleasedSuffix(operation.releasedIds);
379
1250
  if (operation.verificationNudge)
380
1251
  text += `\n\n${VERIFICATION_NUDGE_NOTE}`;
381
1252
  return text;
382
1253
  }
383
1254
  case "delete":
384
- return `Deleted #${operation.id}: ${operation.subject}`;
1255
+ return `Deleted #${operation.id}: ${formatDisplayText(operation.subject, TODO_MAX_SUBJECT_LENGTH)}${formatReleasedSuffix(operation.releasedIds)}`;
385
1256
  case "clear":
386
1257
  return `Cleared ${operation.count} tasks`;
387
- case "list": {
388
- let view = state.items;
389
- // An explicit deleted-status query is sufficient intent to include tombstones.
390
- if (!operation.includeDeleted && operation.status !== "deleted")
391
- view = view.filter((item) => item.status !== "deleted");
392
- if (operation.status)
393
- view = view.filter((item) => item.status === operation.status);
394
- return formatBoundedList(view, operation.status, operation.includeDeleted);
395
- }
1258
+ case "list":
1259
+ return formatBoundedList(getListView(operation, state), state, operation);
396
1260
  case "get":
397
1261
  return formatDetailItem(operation.item, state);
398
1262
  case "error":
@@ -415,38 +1279,56 @@ function needsVerificationNudge(state) {
415
1279
  return !completed.some((item) => VERIFICATION_PATTERN.test(item.subject) ||
416
1280
  (item.description !== undefined && VERIFICATION_PATTERN.test(item.description)));
417
1281
  }
418
- function formatBoundedList(view, statusFilter, includeDeleted) {
1282
+ function formatBoundedList(view, state, operation) {
419
1283
  if (!view.length)
420
1284
  return "No tasks";
421
- const shown = view.slice(0, LIST_DISPLAY_MAX_ITEMS);
422
- const lines = shown.map(formatListItem);
1285
+ const shown = view.slice(0, operation.limit);
1286
+ const lines = shown.map((item) => formatListItem(item, state));
423
1287
  const omitted = view.length - shown.length;
424
1288
  if (omitted > 0) {
425
- const filterHint = statusFilter ? `status=${statusFilter}` : includeDeleted ? "includeDeleted=true" : "list";
426
- lines.push(`… and ${omitted} more (${shown.length} of ${view.length} shown). Narrow with ${filterHint} or use get with id=.`);
1289
+ if (!operation.legacyOutput) {
1290
+ lines.push(`… and ${omitted} more; next page: afterId=${shown.at(-1)?.id}.`);
1291
+ }
1292
+ else {
1293
+ // Do not suggest a filter that is already applied.
1294
+ const hint = operation.status ? "use get with id= for details" : "narrow with status= or use get with id=";
1295
+ lines.push(`… and ${omitted} more (${shown.length} of ${view.length} shown); ${hint}.`);
1296
+ }
427
1297
  }
428
1298
  return lines.join("\n");
429
1299
  }
430
- function formatListItem(item) {
431
- const deps = item.blockedBy?.length ? ` blockedBy=${item.blockedBy.map((id) => `#${id}`).join(",")}` : "";
432
- const active = item.status === "in_progress" && item.activeForm ? ` (${item.activeForm})` : "";
433
- return `[${item.status}] #${item.id} ${item.subject}${active}${deps}`;
1300
+ function formatListIdList(ids) {
1301
+ const shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);
1302
+ return ids.length > shown.length ? `${shown.join(",")},…+${ids.length - shown.length}` : shown.join(",");
1303
+ }
1304
+ function formatListItem(item, state) {
1305
+ const unresolved = unresolvedDependencyIds(state, item);
1306
+ const deps = unresolved.length ? ` blockedBy=${formatListIdList(unresolved)}` : "";
1307
+ const owner = item.owner ? ` @${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}` : "";
1308
+ const active = item.status === "in_progress" && item.activeForm
1309
+ ? ` (${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)})`
1310
+ : "";
1311
+ return `[${item.status}] #${item.id} ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}${active}${owner}${deps}`;
1312
+ }
1313
+ function formatIdList(ids) {
1314
+ const shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);
1315
+ return ids.length > shown.length ? `${shown.join(", ")}, … +${ids.length - shown.length}` : shown.join(", ");
434
1316
  }
435
1317
  function formatDetailItem(item, state) {
436
- const lines = [`#${item.id} [${item.status}] ${item.subject}`];
1318
+ const lines = [`#${item.id} [${item.status}] ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}`];
437
1319
  if (item.description)
438
- lines.push(`description: ${item.description}`);
1320
+ lines.push(`description: ${formatDisplayText(item.description, TODO_MAX_DESCRIPTION_LENGTH)}`);
439
1321
  if (item.activeForm)
440
- lines.push(`activeForm: ${item.activeForm}`);
1322
+ lines.push(`activeForm: ${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)}`);
441
1323
  if (item.blockedBy?.length)
442
- lines.push(`blockedBy: ${item.blockedBy.map((id) => `#${id}`).join(", ")}`);
1324
+ lines.push(`blockedBy: ${formatIdList(item.blockedBy)}`);
443
1325
  const blocks = state.items
444
1326
  .filter((candidate) => candidate.blockedBy?.includes(item.id))
445
1327
  .map((candidate) => candidate.id);
446
1328
  if (blocks.length)
447
- lines.push(`blocks: ${blocks.map((id) => `#${id}`).join(", ")}`);
1329
+ lines.push(`blocks: ${formatIdList(blocks)}`);
448
1330
  if (item.owner)
449
- lines.push(`owner: ${item.owner}`);
1331
+ lines.push(`owner: ${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}`);
450
1332
  return lines.join("\n");
451
1333
  }
452
1334
  //# sourceMappingURL=state.js.map