@astralyn/pi 0.82.5 → 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 (94) hide show
  1. package/CHANGELOG.md +28 -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/bash-executor.d.ts.map +1 -1
  6. package/dist/core/bash-executor.js +4 -22
  7. package/dist/core/bash-executor.js.map +1 -1
  8. package/dist/core/extensions/index.d.ts +1 -1
  9. package/dist/core/extensions/index.d.ts.map +1 -1
  10. package/dist/core/extensions/index.js.map +1 -1
  11. package/dist/core/extensions/types.d.ts +28 -5
  12. package/dist/core/extensions/types.d.ts.map +1 -1
  13. package/dist/core/extensions/types.js.map +1 -1
  14. package/dist/core/tools/output-accumulator.d.ts +3 -13
  15. package/dist/core/tools/output-accumulator.d.ts.map +1 -1
  16. package/dist/core/tools/output-accumulator.js +6 -36
  17. package/dist/core/tools/output-accumulator.js.map +1 -1
  18. package/dist/extensions/plan/constants.d.ts +11 -2
  19. package/dist/extensions/plan/constants.d.ts.map +1 -1
  20. package/dist/extensions/plan/constants.js +14 -2
  21. package/dist/extensions/plan/constants.js.map +1 -1
  22. package/dist/extensions/plan/index.d.ts +0 -18
  23. package/dist/extensions/plan/index.d.ts.map +1 -1
  24. package/dist/extensions/plan/index.js +35 -3
  25. package/dist/extensions/plan/index.js.map +1 -1
  26. package/dist/extensions/plan/view.d.ts +36 -0
  27. package/dist/extensions/plan/view.d.ts.map +1 -0
  28. package/dist/extensions/plan/view.js +74 -0
  29. package/dist/extensions/plan/view.js.map +1 -0
  30. package/dist/extensions/router/stream.d.ts.map +1 -1
  31. package/dist/extensions/router/stream.js +44 -0
  32. package/dist/extensions/router/stream.js.map +1 -1
  33. package/dist/extensions/subagent/activity.d.ts.map +1 -1
  34. package/dist/extensions/subagent/activity.js +25 -4
  35. package/dist/extensions/subagent/activity.js.map +1 -1
  36. package/dist/extensions/subagent/index.d.ts.map +1 -1
  37. package/dist/extensions/subagent/index.js +14 -0
  38. package/dist/extensions/subagent/index.js.map +1 -1
  39. package/dist/extensions/subagent/render.d.ts.map +1 -1
  40. package/dist/extensions/subagent/render.js +243 -116
  41. package/dist/extensions/subagent/render.js.map +1 -1
  42. package/dist/extensions/subagent/resolve.d.ts.map +1 -1
  43. package/dist/extensions/subagent/resolve.js +13 -9
  44. package/dist/extensions/subagent/resolve.js.map +1 -1
  45. package/dist/extensions/subagent/runner.d.ts.map +1 -1
  46. package/dist/extensions/subagent/runner.js +4 -2
  47. package/dist/extensions/subagent/runner.js.map +1 -1
  48. package/dist/extensions/subagent/schema.d.ts.map +1 -1
  49. package/dist/extensions/subagent/schema.js +2 -2
  50. package/dist/extensions/subagent/schema.js.map +1 -1
  51. package/dist/extensions/todo/constants.d.ts +17 -6
  52. package/dist/extensions/todo/constants.d.ts.map +1 -1
  53. package/dist/extensions/todo/constants.js +22 -9
  54. package/dist/extensions/todo/constants.js.map +1 -1
  55. package/dist/extensions/todo/index.d.ts +0 -14
  56. package/dist/extensions/todo/index.d.ts.map +1 -1
  57. package/dist/extensions/todo/index.js +13 -1
  58. package/dist/extensions/todo/index.js.map +1 -1
  59. package/dist/extensions/todo/schema.d.ts +53 -2
  60. package/dist/extensions/todo/schema.d.ts.map +1 -1
  61. package/dist/extensions/todo/schema.js +57 -8
  62. package/dist/extensions/todo/schema.js.map +1 -1
  63. package/dist/extensions/todo/state.d.ts +16 -5
  64. package/dist/extensions/todo/state.d.ts.map +1 -1
  65. package/dist/extensions/todo/state.js +866 -130
  66. package/dist/extensions/todo/state.js.map +1 -1
  67. package/dist/extensions/todo/view.d.ts +16 -1
  68. package/dist/extensions/todo/view.d.ts.map +1 -1
  69. package/dist/extensions/todo/view.js +283 -1
  70. package/dist/extensions/todo/view.js.map +1 -1
  71. package/dist/modes/interactive/components/extension-selector.d.ts +6 -1
  72. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  73. package/dist/modes/interactive/components/extension-selector.js +14 -6
  74. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  75. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  76. package/dist/modes/interactive/components/tool-execution.js +6 -0
  77. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  78. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  79. package/dist/modes/interactive/interactive-mode.js +7 -1
  80. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  81. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  82. package/dist/modes/rpc/rpc-mode.js +8 -1
  83. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  84. package/docs/bundled/extensions/plan.md +6 -4
  85. package/docs/bundled/extensions/subagent.md +2 -2
  86. package/docs/bundled/extensions/todo.md +8 -6
  87. package/docs/bundled/tool-presentation.md +1 -1
  88. package/docs/extensions.md +14 -0
  89. package/npm-shrinkwrap.json +2 -2
  90. package/package.json +1 -1
  91. package/dist/utils/output-decoder.d.ts +0 -52
  92. package/dist/utils/output-decoder.d.ts.map +0 -1
  93. package/dist/utils/output-decoder.js +0 -134
  94. 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
@@ -32,6 +47,173 @@ export function disposeTodoSession(sid) {
32
47
  if (activeSid === sid)
33
48
  activeSid = "";
34
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
+ }
35
217
  export function cloneState(source) {
36
218
  return {
37
219
  items: source.items.map((item) => {
@@ -40,8 +222,13 @@ export function cloneState(source) {
40
222
  clone.blockedBy = [...item.blockedBy];
41
223
  else
42
224
  delete clone.blockedBy;
43
- if (item.metadata)
44
- 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
+ }
45
232
  else
46
233
  delete clone.metadata;
47
234
  return clone;
@@ -87,6 +274,7 @@ function error(state, message) {
87
274
  // should learn about addBlockedBy, not lose the edit).
88
275
  const ACTION_PARAMS = {
89
276
  create: new Set(["subject", "description", "activeForm", "status", "blockedBy", "owner", "metadata"]),
277
+ create_many: new Set(["items"]),
90
278
  update: new Set([
91
279
  "id",
92
280
  "subject",
@@ -100,10 +288,10 @@ const ACTION_PARAMS = {
100
288
  "owner",
101
289
  "metadata",
102
290
  ]),
103
- list: new Set(["status", "includeDeleted"]),
291
+ list: new Set(["status", "includeDeleted", "limit", "afterId", "query", "unblockedOnly"]),
104
292
  get: new Set(["id"]),
105
293
  delete: new Set(["id"]),
106
- clear: new Set(),
294
+ clear: new Set(["confirm", "expectedCount"]),
107
295
  };
108
296
  const UPDATE_ONLY_EDGE_PARAMS = new Set(["addBlockedBy", "removeBlockedBy", "addBlocks", "removeBlocks"]);
109
297
  function inapplicableParamError(action, key) {
@@ -125,6 +313,57 @@ function findInapplicableParam(params) {
125
313
  }
126
314
  return undefined;
127
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
+ }
128
367
  function isTransitionAllowed(from, to) {
129
368
  // `deleted` is the only terminal status; `completed` can be reopened to
130
369
  // in_progress or pending so a premature completion recovers without losing
@@ -147,8 +386,6 @@ function findItem(state, id) {
147
386
  return state.items.find((item) => item.id === id);
148
387
  }
149
388
  function validateDependencies(state, deps, currentId) {
150
- if (!deps?.length)
151
- return undefined;
152
389
  for (const dep of deps) {
153
390
  if (dep === currentId)
154
391
  return `cannot block #${currentId} on itself`;
@@ -162,8 +399,6 @@ function validateDependencies(state, deps, currentId) {
162
399
  }
163
400
  /** All listed dependencies must be completed before starting or closing a task. */
164
401
  function validateDependenciesReady(state, deps) {
165
- if (!deps?.length)
166
- return undefined;
167
402
  for (const dep of deps) {
168
403
  const item = findItem(state, dep);
169
404
  if (!item || item.status !== "completed") {
@@ -180,75 +415,255 @@ export function unresolvedDependencyIds(state, item) {
180
415
  export function dependenciesSatisfied(state, item) {
181
416
  return unresolvedDependencyIds(state, item).length === 0;
182
417
  }
183
- /** True when any task whose deps change in nextDepsById can reach itself through blockedBy edges. */
418
+ /** True when the resulting dependency graph contains a cycle. */
184
419
  function createsCycle(items, nextDepsById) {
185
420
  const depsById = new Map();
186
421
  for (const item of items)
187
422
  depsById.set(item.id, item.blockedBy ?? []);
188
423
  for (const [id, deps] of nextDepsById)
189
424
  depsById.set(id, deps);
190
- const seen = new Set();
191
- const stack = new Set();
192
- function visit(node) {
193
- if (stack.has(node))
194
- return true;
195
- if (seen.has(node))
196
- return false;
197
- seen.add(node);
198
- stack.add(node);
199
- for (const dep of depsById.get(node) ?? []) {
200
- if (visit(dep))
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)
201
448
  return true;
449
+ if (color === 2)
450
+ continue;
451
+ colors.set(dependencyId, 1);
452
+ stack.push({ id: dependencyId, nextDependencyIndex: 0 });
202
453
  }
203
- stack.delete(node);
204
- return false;
205
- }
206
- for (const id of nextDepsById.keys()) {
207
- if (visit(id))
208
- return true;
209
454
  }
210
455
  return false;
211
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
+ };
605
+ }
212
606
  export function applyTodoMutation(input, params) {
213
607
  const state = cloneState(input);
608
+ if (!isTodoAction(params.action))
609
+ return error(state, `unknown action: ${String(params.action)}`);
214
610
  const inapplicable = findInapplicableParam(params);
215
611
  if (inapplicable)
216
612
  return error(state, inapplicable);
217
613
  switch (params.action) {
218
614
  case "create": {
219
- const subject = params.subject?.trim();
220
- if (!subject)
615
+ if (typeof params.subject !== "string" || !params.subject.trim())
221
616
  return error(state, "subject required for create");
222
- if (!params.description?.trim()) {
223
- return error(state, "description required for create: state what done means for this task");
224
- }
617
+ const taskTextError = validateTaskTextFields(params, true);
618
+ if (taskTextError)
619
+ return error(state, taskTextError);
225
620
  if (params.status !== undefined && params.status !== "pending") {
226
621
  return error(state, "create always starts pending; use update to start or complete a task");
227
622
  }
228
- 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);
229
627
  if (dependencyError)
230
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
+ }
231
644
  const item = {
232
645
  id: state.nextId,
233
- subject,
646
+ subject: params.subject.trim(),
234
647
  status: "pending",
235
648
  description: params.description,
236
649
  };
237
650
  if (params.activeForm)
238
651
  item.activeForm = params.activeForm;
239
- if (params.blockedBy?.length)
240
- item.blockedBy = Array.from(new Set(params.blockedBy));
652
+ if (blockedBy.length)
653
+ item.blockedBy = blockedBy;
241
654
  if (params.owner)
242
655
  item.owner = params.owner;
243
- if (params.metadata)
244
- item.metadata = { ...params.metadata };
656
+ if (metadata)
657
+ item.metadata = metadata;
245
658
  return {
246
659
  state: { items: [...state.items, item], nextId: state.nextId + 1 },
247
660
  operation: { kind: "create", id: item.id },
248
661
  };
249
662
  }
663
+ case "create_many":
664
+ return createMany(state, params);
250
665
  case "update": {
251
- if (params.id === undefined)
666
+ if (!isPositiveInteger(params.id))
252
667
  return error(state, "id required for update");
253
668
  const index = state.items.findIndex((item) => item.id === params.id);
254
669
  if (index === -1)
@@ -256,12 +671,24 @@ export function applyTodoMutation(input, params) {
256
671
  const current = state.items[index];
257
672
  if (current.status === "deleted")
258
673
  return error(state, `#${params.id} is deleted and cannot be updated`);
259
- if (params.subject !== undefined && !params.subject.trim())
260
- return error(state, "subject cannot be empty");
261
- const edgeChange = (params.addBlockedBy?.length ?? 0) > 0 ||
262
- (params.removeBlockedBy?.length ?? 0) > 0 ||
263
- (params.addBlocks?.length ?? 0) > 0 ||
264
- (params.removeBlocks?.length ?? 0) > 0;
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;
265
692
  const fieldChange = params.subject !== undefined ||
266
693
  params.description !== undefined ||
267
694
  params.activeForm !== undefined ||
@@ -270,10 +697,6 @@ export function applyTodoMutation(input, params) {
270
697
  if (!fieldChange && !edgeChange && params.status === undefined) {
271
698
  return error(state, "update requires at least one field");
272
699
  }
273
- // `status: deleted` is the update form of delete; keep its dependency
274
- // cleanup identical to the dedicated delete action. Other edits do not
275
- // combine with it: a tombstone is immutable, so applying them would
276
- // silently produce an unreachable revision.
277
700
  if (params.status === "deleted") {
278
701
  if (fieldChange || edgeChange) {
279
702
  return error(state, "status deleted cannot be combined with other changes; apply edits first or delete in a separate call");
@@ -294,34 +717,65 @@ export function applyTodoMutation(input, params) {
294
717
  if (!isTransitionAllowed(current.status, nextStatus)) {
295
718
  return error(state, `illegal transition ${current.status} -> ${nextStatus}`);
296
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
+ }
297
751
  // Collect every task whose blockedBy changes: this task (addBlockedBy /
298
752
  // removeBlockedBy) plus reverse-edge targets (addBlocks / removeBlocks).
299
753
  const nextDepsById = new Map();
300
754
  let nextDeps = current.blockedBy ? [...current.blockedBy] : [];
301
- if (params.removeBlockedBy?.length) {
302
- const remove = new Set(params.removeBlockedBy);
755
+ if (removeBlockedBy.length) {
756
+ const remove = new Set(removeBlockedBy);
303
757
  nextDeps = nextDeps.filter((id) => !remove.has(id));
304
758
  }
305
- if (params.addBlockedBy?.length) {
306
- const dependencyError = validateDependencies(state, params.addBlockedBy, current.id);
759
+ if (addBlockedBy.length) {
760
+ const dependencyError = validateDependencies(state, addBlockedBy, current.id);
307
761
  if (dependencyError)
308
762
  return error(state, dependencyError);
309
- for (const dep of params.addBlockedBy) {
763
+ for (const dep of addBlockedBy) {
310
764
  if (!nextDeps.includes(dep))
311
765
  nextDeps.push(dep);
312
766
  }
313
767
  }
314
768
  nextDepsById.set(current.id, nextDeps);
315
- if (params.removeBlocks?.length) {
316
- const release = new Set(params.removeBlocks);
769
+ if (removeBlocks.length) {
770
+ const release = new Set(removeBlocks);
317
771
  for (const candidate of state.items) {
318
772
  if (!release.has(candidate.id) || !candidate.blockedBy?.includes(current.id))
319
773
  continue;
320
774
  nextDepsById.set(candidate.id, candidate.blockedBy.filter((dependencyId) => dependencyId !== current.id));
321
775
  }
322
776
  }
323
- if (params.addBlocks?.length) {
324
- for (const targetId of params.addBlocks) {
777
+ if (addBlocks.length) {
778
+ for (const targetId of addBlocks) {
325
779
  if (targetId === current.id)
326
780
  return error(state, `cannot block #${targetId} on itself`);
327
781
  const target = findItem(state, targetId);
@@ -338,9 +792,8 @@ export function applyTodoMutation(input, params) {
338
792
  if (createsCycle(state.items, nextDepsById)) {
339
793
  return error(state, "blockedBy would create a cycle");
340
794
  }
341
- // Readiness gates only status *changes*: an already-active task may take
342
- // on a new incomplete dependency — a prerequisite discovered mid-work.
343
- // The overlay flags it as "deps incomplete" instead.
795
+ // Readiness gates explicit status changes. An already active task that
796
+ // takes on a new incomplete edge is instead automatically demoted below.
344
797
  if ((nextStatus === "in_progress" || nextStatus === "completed") && current.status !== nextStatus) {
345
798
  const readyError = validateDependenciesReady(state, nextDeps);
346
799
  if (readyError)
@@ -373,65 +826,107 @@ export function applyTodoMutation(input, params) {
373
826
  else
374
827
  delete updated.blockedBy;
375
828
  if (params.metadata !== undefined) {
376
- const merged = { ...(current.metadata ?? {}) };
377
- for (const [key, value] of Object.entries(params.metadata)) {
378
- if (value === null)
379
- delete merged[key];
380
- else
381
- merged[key] = value;
382
- }
383
- if (Object.keys(merged).length)
384
- updated.metadata = merged;
829
+ if (metadata)
830
+ updated.metadata = metadata;
385
831
  else
386
832
  delete updated.metadata;
387
833
  }
388
834
  const items = state.items.map((task) => {
389
835
  if (task.id === current.id)
390
- return task;
836
+ return updated;
391
837
  const deps = nextDepsById.get(task.id);
392
838
  if (deps === undefined)
393
839
  return task;
394
840
  return deps.length ? { ...task, blockedBy: deps } : omitBlockedBy(task);
395
841
  });
396
- items[index] = updated;
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
+ }
397
863
  // Exactly one in_progress: demote any other active tasks to pending.
398
864
  const demotedIds = [];
399
- if (nextStatus === "in_progress") {
400
- for (let i = 0; i < items.length; i++) {
401
- const item = items[i];
402
- if (item.id === updated.id)
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")
403
870
  continue;
404
- if (item.status !== "in_progress")
405
- continue;
406
- items[i] = { ...item, status: "pending" };
871
+ items[itemIndex] = { ...item, status: "pending" };
407
872
  demotedIds.push(item.id);
408
873
  }
409
874
  }
410
875
  const nextState = { items, nextId: state.nextId };
411
- const newlyCompleted = current.status !== "completed" && nextStatus === "completed";
876
+ const finalItem = items[index];
877
+ const newlyCompleted = current.status !== "completed" && finalItem.status === "completed";
412
878
  return {
413
879
  state: nextState,
414
880
  operation: {
415
881
  kind: "update",
416
- id: updated.id,
882
+ id: finalItem.id,
417
883
  from: current.status,
418
- to: updated.status,
884
+ to: finalItem.status,
419
885
  ...(demotedIds.length ? { demotedIds } : {}),
886
+ ...(blockedIds.length ? { blockedIds } : {}),
420
887
  ...(newlyCompleted && needsVerificationNudge(nextState) ? { verificationNudge: true } : {}),
421
888
  },
422
889
  };
423
890
  }
424
- 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
+ }
425
911
  return {
426
912
  state,
427
913
  operation: {
428
914
  kind: "list",
429
915
  status: params.status,
430
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,
431
925
  },
432
926
  };
927
+ }
433
928
  case "get": {
434
- if (params.id === undefined)
929
+ if (!isPositiveInteger(params.id))
435
930
  return error(state, "id required for get");
436
931
  const item = findItem(state, params.id);
437
932
  if (!item)
@@ -439,7 +934,7 @@ export function applyTodoMutation(input, params) {
439
934
  return { state, operation: { kind: "get", item } };
440
935
  }
441
936
  case "delete": {
442
- if (params.id === undefined)
937
+ if (!isPositiveInteger(params.id))
443
938
  return error(state, "id required for delete");
444
939
  const index = state.items.findIndex((item) => item.id === params.id);
445
940
  if (index === -1)
@@ -459,6 +954,11 @@ export function applyTodoMutation(input, params) {
459
954
  };
460
955
  }
461
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
+ }
462
962
  return {
463
963
  // IDs remain monotonic across a clear so stale references cannot point
464
964
  // at an unrelated task created later on the same conversation branch.
@@ -466,38 +966,236 @@ export function applyTodoMutation(input, params) {
466
966
  operation: { kind: "clear", count: state.items.length },
467
967
  };
468
968
  }
469
- return error(state, `unknown action: ${params.action}`);
470
969
  }
471
- function isTodoDetails(value) {
472
- 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)
473
981
  return false;
474
- const record = value;
475
- 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);
476
1089
  }
477
1090
  export function replayTodosFromBranch(ctx) {
478
- // 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.
479
1093
  const branch = Array.from(ctx.sessionManager.getBranch());
480
- for (let i = branch.length - 1; i >= 0; i--) {
481
- const item = branch[i];
482
- if (item.type !== "message")
1094
+ for (let index = branch.length - 1; index >= 0; index--) {
1095
+ const entry = branch[index];
1096
+ if (entry.type !== "message")
483
1097
  continue;
484
- if (item.message?.role !== "toolResult" || item.message.toolName !== TODO_TOOL_NAME)
1098
+ if (entry.message?.role !== "toolResult" || entry.message.toolName !== TODO_TOOL_NAME)
485
1099
  continue;
486
- 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.
487
1106
  continue;
488
- return cloneState({
489
- items: item.message.details.items,
490
- nextId: item.message.details.nextId,
491
- });
1107
+ }
1108
+ if (state)
1109
+ return cloneState(state);
492
1110
  }
493
1111
  return cloneState(EMPTY_TODO_STATE);
494
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
+ }
495
1190
  export function buildTodoDetails(params, next) {
1191
+ const snapshot = cloneState(next);
496
1192
  return {
1193
+ schemaVersion: TODO_DETAILS_SCHEMA_VERSION,
497
1194
  action: params.action,
498
- params: params,
499
- items: cloneState(next).items,
500
- nextId: next.nextId,
1195
+ params: cloneDetailsParams(params),
1196
+ operation: buildTodoOperationSummary(params, snapshot),
1197
+ items: snapshot.items,
1198
+ nextId: snapshot.nextId,
501
1199
  };
502
1200
  }
503
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.";
@@ -506,36 +1204,59 @@ function formatReleasedSuffix(releasedIds) {
506
1204
  return "";
507
1205
  return `; released ${releasedIds.map((id) => `#${id}`).join(",")} (no longer blocked)`;
508
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
+ }
509
1230
  export function formatTodoContent(operation, state) {
510
1231
  switch (operation.kind) {
511
1232
  case "create": {
512
1233
  const item = findItem(state, operation.id);
513
- 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}`;
514
1237
  }
1238
+ case "create_many":
1239
+ return `Created ${operation.ids.length} tasks: ${operation.ids.map((id) => `#${id}`).join(", ")}`;
515
1240
  case "update": {
516
1241
  const transition = operation.from === operation.to ? "" : ` (${operation.from} -> ${operation.to})`;
517
1242
  let text = `Updated #${operation.id}${transition}`;
518
1243
  if (operation.demotedIds?.length) {
519
1244
  text += `; ${operation.demotedIds.map((id) => `#${id}`).join(",")} demoted to pending`;
520
1245
  }
1246
+ if (operation.blockedIds?.length) {
1247
+ text += `; ${operation.blockedIds.map((id) => `#${id}`).join(",")} moved to pending (dependencies incomplete)`;
1248
+ }
521
1249
  text += formatReleasedSuffix(operation.releasedIds);
522
1250
  if (operation.verificationNudge)
523
1251
  text += `\n\n${VERIFICATION_NUDGE_NOTE}`;
524
1252
  return text;
525
1253
  }
526
1254
  case "delete":
527
- return `Deleted #${operation.id}: ${operation.subject}${formatReleasedSuffix(operation.releasedIds)}`;
1255
+ return `Deleted #${operation.id}: ${formatDisplayText(operation.subject, TODO_MAX_SUBJECT_LENGTH)}${formatReleasedSuffix(operation.releasedIds)}`;
528
1256
  case "clear":
529
1257
  return `Cleared ${operation.count} tasks`;
530
- case "list": {
531
- let view = state.items;
532
- // An explicit deleted-status query is sufficient intent to include tombstones.
533
- if (!operation.includeDeleted && operation.status !== "deleted")
534
- view = view.filter((item) => item.status !== "deleted");
535
- if (operation.status)
536
- view = view.filter((item) => item.status === operation.status);
537
- return formatBoundedList(view, state, operation.status);
538
- }
1258
+ case "list":
1259
+ return formatBoundedList(getListView(operation, state), state, operation);
539
1260
  case "get":
540
1261
  return formatDetailItem(operation.item, state);
541
1262
  case "error":
@@ -558,41 +1279,56 @@ function needsVerificationNudge(state) {
558
1279
  return !completed.some((item) => VERIFICATION_PATTERN.test(item.subject) ||
559
1280
  (item.description !== undefined && VERIFICATION_PATTERN.test(item.description)));
560
1281
  }
561
- function formatBoundedList(view, state, statusFilter) {
1282
+ function formatBoundedList(view, state, operation) {
562
1283
  if (!view.length)
563
1284
  return "No tasks";
564
- const shown = view.slice(0, LIST_DISPLAY_MAX_ITEMS);
1285
+ const shown = view.slice(0, operation.limit);
565
1286
  const lines = shown.map((item) => formatListItem(item, state));
566
1287
  const omitted = view.length - shown.length;
567
1288
  if (omitted > 0) {
568
- // Do not suggest a filter that is already applied.
569
- const hint = statusFilter ? "use get with id= for details" : "narrow with status= or use get with id=";
570
- lines.push(`… and ${omitted} more (${shown.length} of ${view.length} shown); ${hint}.`);
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
+ }
571
1297
  }
572
1298
  return lines.join("\n");
573
1299
  }
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
+ }
574
1304
  function formatListItem(item, state) {
575
1305
  const unresolved = unresolvedDependencyIds(state, item);
576
- const deps = unresolved.length ? ` blockedBy=${unresolved.map((id) => `#${id}`).join(",")}` : "";
577
- const owner = item.owner ? ` @${item.owner}` : "";
578
- const active = item.status === "in_progress" && item.activeForm ? ` (${item.activeForm})` : "";
579
- return `[${item.status}] #${item.id} ${item.subject}${active}${owner}${deps}`;
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(", ");
580
1316
  }
581
1317
  function formatDetailItem(item, state) {
582
- const lines = [`#${item.id} [${item.status}] ${item.subject}`];
1318
+ const lines = [`#${item.id} [${item.status}] ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}`];
583
1319
  if (item.description)
584
- lines.push(`description: ${item.description}`);
1320
+ lines.push(`description: ${formatDisplayText(item.description, TODO_MAX_DESCRIPTION_LENGTH)}`);
585
1321
  if (item.activeForm)
586
- lines.push(`activeForm: ${item.activeForm}`);
1322
+ lines.push(`activeForm: ${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)}`);
587
1323
  if (item.blockedBy?.length)
588
- lines.push(`blockedBy: ${item.blockedBy.map((id) => `#${id}`).join(", ")}`);
1324
+ lines.push(`blockedBy: ${formatIdList(item.blockedBy)}`);
589
1325
  const blocks = state.items
590
1326
  .filter((candidate) => candidate.blockedBy?.includes(item.id))
591
1327
  .map((candidate) => candidate.id);
592
1328
  if (blocks.length)
593
- lines.push(`blocks: ${blocks.map((id) => `#${id}`).join(", ")}`);
1329
+ lines.push(`blocks: ${formatIdList(blocks)}`);
594
1330
  if (item.owner)
595
- lines.push(`owner: ${item.owner}`);
1331
+ lines.push(`owner: ${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}`);
596
1332
  return lines.join("\n");
597
1333
  }
598
1334
  //# sourceMappingURL=state.js.map