@astralyn/pi 0.82.6 → 0.82.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +1 -1
- package/dist/cli/session-picker.d.ts.map +1 -1
- package/dist/cli/session-picker.js +11 -13
- package/dist/cli/session-picker.js.map +1 -1
- package/dist/core/extensions/types.d.ts +4 -0
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +2 -2
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/tools/bash.d.ts +1 -5
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +16 -41
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/edit.d.ts.map +1 -1
- package/dist/core/tools/edit.js +42 -11
- package/dist/core/tools/edit.js.map +1 -1
- package/dist/core/tools/find.d.ts.map +1 -1
- package/dist/core/tools/find.js +6 -7
- package/dist/core/tools/find.js.map +1 -1
- package/dist/core/tools/grep.d.ts.map +1 -1
- package/dist/core/tools/grep.js +17 -8
- package/dist/core/tools/grep.js.map +1 -1
- package/dist/core/tools/ls.d.ts.map +1 -1
- package/dist/core/tools/ls.js +3 -3
- package/dist/core/tools/ls.js.map +1 -1
- package/dist/core/tools/read.d.ts.map +1 -1
- package/dist/core/tools/read.js +4 -4
- package/dist/core/tools/read.js.map +1 -1
- package/dist/core/tools/render-utils.d.ts +7 -0
- package/dist/core/tools/render-utils.d.ts.map +1 -1
- package/dist/core/tools/render-utils.js +11 -0
- package/dist/core/tools/render-utils.js.map +1 -1
- package/dist/core/tools/write.d.ts.map +1 -1
- package/dist/core/tools/write.js +2 -3
- package/dist/core/tools/write.js.map +1 -1
- package/dist/extensions/deepwiki/render.d.ts.map +1 -1
- package/dist/extensions/deepwiki/render.js +34 -22
- package/dist/extensions/deepwiki/render.js.map +1 -1
- package/dist/extensions/plan/constants.d.ts +32 -4
- package/dist/extensions/plan/constants.d.ts.map +1 -1
- package/dist/extensions/plan/constants.js +37 -6
- package/dist/extensions/plan/constants.js.map +1 -1
- package/dist/extensions/plan/index.d.ts +27 -0
- package/dist/extensions/plan/index.d.ts.map +1 -1
- package/dist/extensions/plan/index.js +150 -44
- package/dist/extensions/plan/index.js.map +1 -1
- package/dist/extensions/plan/schema.d.ts +5 -0
- package/dist/extensions/plan/schema.d.ts.map +1 -1
- package/dist/extensions/plan/schema.js.map +1 -1
- package/dist/extensions/plan/state.d.ts +11 -2
- package/dist/extensions/plan/state.d.ts.map +1 -1
- package/dist/extensions/plan/state.js +31 -9
- package/dist/extensions/plan/state.js.map +1 -1
- package/dist/extensions/plan/storage.d.ts +11 -3
- package/dist/extensions/plan/storage.d.ts.map +1 -1
- package/dist/extensions/plan/storage.js +45 -20
- package/dist/extensions/plan/storage.js.map +1 -1
- package/dist/extensions/plan/view.d.ts +15 -7
- package/dist/extensions/plan/view.d.ts.map +1 -1
- package/dist/extensions/plan/view.js +91 -16
- package/dist/extensions/plan/view.js.map +1 -1
- package/dist/extensions/question/dialog.d.ts.map +1 -1
- package/dist/extensions/question/dialog.js +29 -10
- package/dist/extensions/question/dialog.js.map +1 -1
- package/dist/extensions/question/index.d.ts.map +1 -1
- package/dist/extensions/question/index.js +7 -2
- package/dist/extensions/question/index.js.map +1 -1
- package/dist/extensions/question/render.d.ts +4 -8
- package/dist/extensions/question/render.d.ts.map +1 -1
- package/dist/extensions/question/render.js +202 -29
- package/dist/extensions/question/render.js.map +1 -1
- package/dist/extensions/question/results.d.ts.map +1 -1
- package/dist/extensions/question/results.js +1 -0
- package/dist/extensions/question/results.js.map +1 -1
- package/dist/extensions/question/schema.d.ts.map +1 -1
- package/dist/extensions/question/schema.js +2 -2
- package/dist/extensions/question/schema.js.map +1 -1
- package/dist/extensions/question/types.d.ts +1 -0
- package/dist/extensions/question/types.d.ts.map +1 -1
- package/dist/extensions/question/types.js +1 -1
- package/dist/extensions/question/types.js.map +1 -1
- package/dist/extensions/router/stream.d.ts +1 -0
- package/dist/extensions/router/stream.d.ts.map +1 -1
- package/dist/extensions/router/stream.js +44 -13
- package/dist/extensions/router/stream.js.map +1 -1
- package/dist/extensions/subagent/constants.d.ts +1 -0
- package/dist/extensions/subagent/constants.d.ts.map +1 -1
- package/dist/extensions/subagent/constants.js +1 -0
- package/dist/extensions/subagent/constants.js.map +1 -1
- package/dist/extensions/subagent/index.d.ts.map +1 -1
- package/dist/extensions/subagent/index.js +2 -0
- package/dist/extensions/subagent/index.js.map +1 -1
- package/dist/extensions/subagent/render.d.ts.map +1 -1
- package/dist/extensions/subagent/render.js +121 -48
- package/dist/extensions/subagent/render.js.map +1 -1
- package/dist/extensions/subagent/retry.d.ts +17 -0
- package/dist/extensions/subagent/retry.d.ts.map +1 -0
- package/dist/extensions/subagent/retry.js +41 -0
- package/dist/extensions/subagent/retry.js.map +1 -0
- package/dist/extensions/subagent/runner.d.ts.map +1 -1
- package/dist/extensions/subagent/runner.js +33 -5
- package/dist/extensions/subagent/runner.js.map +1 -1
- package/dist/extensions/subagent/sdk-runner.d.ts +6 -0
- package/dist/extensions/subagent/sdk-runner.d.ts.map +1 -1
- package/dist/extensions/subagent/sdk-runner.js +46 -24
- package/dist/extensions/subagent/sdk-runner.js.map +1 -1
- package/dist/extensions/subagent/types.d.ts +7 -0
- package/dist/extensions/subagent/types.d.ts.map +1 -1
- package/dist/extensions/subagent/types.js.map +1 -1
- package/dist/extensions/todo/index.d.ts.map +1 -1
- package/dist/extensions/todo/index.js +1 -1
- package/dist/extensions/todo/index.js.map +1 -1
- package/dist/extensions/todo/state.d.ts.map +1 -1
- package/dist/extensions/todo/state.js +10 -2
- package/dist/extensions/todo/state.js.map +1 -1
- package/dist/extensions/todo/view.d.ts +1 -1
- package/dist/extensions/todo/view.d.ts.map +1 -1
- package/dist/extensions/todo/view.js +179 -34
- package/dist/extensions/todo/view.js.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +2 -1
- package/dist/migrations.js.map +1 -1
- package/dist/modes/interactive/components/armin.d.ts.map +1 -1
- package/dist/modes/interactive/components/armin.js +5 -0
- package/dist/modes/interactive/components/armin.js.map +1 -1
- package/dist/modes/interactive/components/bash-execution.d.ts +1 -0
- package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/bash-execution.js +4 -2
- package/dist/modes/interactive/components/bash-execution.js.map +1 -1
- package/dist/modes/interactive/components/countdown-timer.d.ts +13 -6
- package/dist/modes/interactive/components/countdown-timer.d.ts.map +1 -1
- package/dist/modes/interactive/components/countdown-timer.js +54 -17
- package/dist/modes/interactive/components/countdown-timer.js.map +1 -1
- package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/extension-selector.js +10 -6
- package/dist/modes/interactive/components/extension-selector.js.map +1 -1
- package/dist/modes/interactive/components/keybinding-hints.d.ts +11 -1
- package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
- package/dist/modes/interactive/components/keybinding-hints.js +56 -0
- package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
- package/dist/modes/interactive/components/session-selector.d.ts +3 -0
- package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/session-selector.js +52 -2
- package/dist/modes/interactive/components/session-selector.js.map +1 -1
- package/dist/modes/interactive/components/tool-execution.d.ts +10 -0
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +133 -18
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/dist/modes/interactive/components/tool-group.d.ts +1 -0
- package/dist/modes/interactive/components/tool-group.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-group.js +5 -0
- package/dist/modes/interactive/components/tool-group.js.map +1 -1
- package/dist/modes/interactive/components/tree-selector.d.ts +10 -1
- package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/tree-selector.js +55 -2
- package/dist/modes/interactive/components/tree-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +5 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +72 -18
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/dist/modes/interactive/theme/theme.js +3 -2
- package/dist/modes/interactive/theme/theme.js.map +1 -1
- package/dist/utils/paths.d.ts +6 -0
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +8 -0
- package/dist/utils/paths.js.map +1 -1
- package/docs/bundled/README.md +1 -0
- package/docs/bundled/extensions/deepwiki.md +1 -1
- package/docs/bundled/extensions/plan.md +10 -9
- package/docs/bundled/extensions/question.md +7 -5
- package/docs/bundled/extensions/router.md +3 -4
- package/docs/bundled/extensions/statusline.md +0 -1
- package/docs/bundled/extensions/subagent.md +4 -3
- package/docs/bundled/extensions/todo.md +1 -1
- package/docs/bundled/tool-presentation.md +17 -16
- package/docs/extensions.md +20 -5
- package/docs/rpc.md +1 -1
- package/docs/usage.md +1 -1
- package/npm-shrinkwrap.json +6 -6
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../../src/extensions/todo/state.ts"],"names":[],"mappings":"AAiBA,OAAO,EAIN,KAAK,WAAW,EAChB,KAAK,QAAQ,EAEb,KAAK,UAAU,EACf,KAAK,SAAS,EACd,KAAK,UAAU,EACf,MAAM,aAAa,CAAC;AAErB,KAAK,SAAS,GACX;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,GAAG,EAAE,MAAM,EAAE,CAAA;CAAE,GACtC;IACA,IAAI,EAAE,QAAQ,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,UAAU,CAAC;IACjB,EAAE,EAAE,UAAU,CAAC;IACf,wFAAsF;IACtF,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,iFAAiF;IACjF,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC3B,GACD;IACA,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;IACvB,uDAAuD;IACvD,YAAY,EAAE,OAAO,CAAC;CACrB,GACD;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GACvE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAqDtC,MAAM,WAAW,cAAc;IAC9B,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,SAAS,CAAC;CACrB;AAWD,kFAAkF;AAClF,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAEtD;AAED,8FAA8F;AAC9F,wBAAgB,YAAY,IAAI,SAAS,CAExC;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAEtD;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAErD;AAED,uEAAuE;AACvE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAIpD;AA6KD,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,GAAG,SAAS,CAevD;AAkKD,iFAAiF;AACjF,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,QAAQ,GAAG,MAAM,EAAE,CAElF;AAED,6FAA6F;AAC7F,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,QAAQ,GAAG,OAAO,CAE/E;AAgLD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,GAAG,cAAc,CAsVtF;AA2GD,wBAAgB,qBAAqB,CAAC,GAAG,EAAE;IAAE,cAAc,EAAE;QAAE,SAAS,IAAI,QAAQ,CAAC,OAAO,CAAC,CAAA;KAAE,CAAA;CAAE,GAAG,SAAS,CAqB5G;AA4ED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,GAAG,WAAW,CAUjF;AAoCD,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,GAAG,MAAM,CAkChF","sourcesContent":["import {\n\tLIST_DISPLAY_MAX_ITEMS,\n\tTODO_MAX_ACTIVE_FORM_LENGTH,\n\tTODO_MAX_BATCH_ITEMS,\n\tTODO_MAX_BATCH_KEY_LENGTH,\n\tTODO_MAX_BLOCKED_BY,\n\tTODO_MAX_DESCRIPTION_LENGTH,\n\tTODO_MAX_LIST_LIMIT,\n\tTODO_MAX_LIST_QUERY_LENGTH,\n\tTODO_MAX_METADATA_BYTES,\n\tTODO_MAX_METADATA_DEPTH,\n\tTODO_MAX_METADATA_ENTRIES,\n\tTODO_MAX_METADATA_KEY_LENGTH,\n\tTODO_MAX_OWNER_LENGTH,\n\tTODO_MAX_SUBJECT_LENGTH,\n\tTODO_TOOL_NAME,\n} from \"./constants.ts\";\nimport {\n\tEMPTY_TODO_STATE,\n\tTODO_DETAILS_SCHEMA_VERSION,\n\ttype TodoAction,\n\ttype TodoDetails,\n\ttype TodoItem,\n\ttype TodoOperationSummary,\n\ttype TodoParams,\n\ttype TodoState,\n\ttype TodoStatus,\n} from \"./schema.ts\";\n\ntype Operation =\n\t| { kind: \"create\"; id: number }\n\t| { kind: \"create_many\"; ids: number[] }\n\t| {\n\t\t\tkind: \"update\";\n\t\t\tid: number;\n\t\t\tfrom: TodoStatus;\n\t\t\tto: TodoStatus;\n\t\t\t/** Other tasks auto-demoted from in_progress → pending to keep exactly one active. */\n\t\t\tdemotedIds?: number[];\n\t\t\t/** Active tasks demoted because a new dependency is still incomplete. */\n\t\t\tblockedIds?: number[];\n\t\t\t/** Pending dependents left fully unblocked by an update to status deleted. */\n\t\t\treleasedIds?: number[];\n\t\t\t/** Soft note when the list is fully closed without a verification-style task. */\n\t\t\tverificationNudge?: boolean;\n\t }\n\t| {\n\t\t\tkind: \"list\";\n\t\t\tstatus?: TodoStatus;\n\t\t\tincludeDeleted: boolean;\n\t\t\tlimit: number;\n\t\t\tafterId?: number;\n\t\t\tquery?: string;\n\t\t\tunblockedOnly: boolean;\n\t\t\t/** Keep the historical no-options list text stable. */\n\t\t\tlegacyOutput: boolean;\n\t }\n\t| { kind: \"get\"; item: TodoItem }\n\t| { kind: \"delete\"; id: number; subject: string; releasedIds?: number[] }\n\t| { kind: \"clear\"; count: number }\n\t| { kind: \"error\"; message: string };\n\n/** Subjects/descriptions that count as a verification step (CC-style soft nudge). */\nconst VERIFICATION_PATTERN = /verif|test|check|review/i;\nconst TODO_STATUSES: ReadonlySet<TodoStatus> = new Set([\"pending\", \"in_progress\", \"completed\", \"deleted\"]);\nconst TODO_ACTIONS: ReadonlySet<TodoAction> = new Set([\n\t\"create\",\n\t\"create_many\",\n\t\"update\",\n\t\"list\",\n\t\"get\",\n\t\"delete\",\n\t\"clear\",\n]);\nconst LEGACY_METADATA_LIMITS = {\n\tmaxDepth: 64,\n\tmaxEntries: 100_000,\n\tmaxBytes: 16 * 1024 * 1024,\n};\n\ninterface MetadataLimits {\n\tmaxDepth: number;\n\tmaxEntries: number;\n\tmaxBytes: number;\n\tmaxKeyLength?: number;\n}\n\ninterface MetadataStats {\n\tentries: number;\n\tbytes: number;\n}\n\ninterface ValidMetadata {\n\tmetadata: Record<string, unknown>;\n}\n\ninterface InvalidMetadata {\n\terror: string;\n}\n\ntype MetadataValidation = ValidMetadata | InvalidMetadata;\n\ninterface BatchItem {\n\tkey?: string;\n\tsubject: string;\n\tdescription: string;\n\tactiveForm?: string;\n\tblockedBy: number[];\n\tblockedByKeys: string[];\n\towner?: string;\n\tmetadata?: Record<string, unknown>;\n}\n\nexport interface MutationResult {\n\tstate: TodoState;\n\toperation: Operation;\n}\n\n// ---- per-session state ------------------------------------------------------\n// Keyed by session id like rewind's engine state: one Pi process can host more\n// than one session over its lifetime (resume, /tree branch switches), and a\n// module-level singleton would leak one session's list into another. Tool\n// renderers get no ctx, so the active session is a module-level pointer kept\n// current by execute and the lifecycle handlers (which do have ctx).\nconst states = new Map<string, TodoState>();\nlet activeSid = \"\";\n\n/** Point the module at a session's state bucket. Call wherever ctx is in hand. */\nexport function setActiveTodoSession(sid: string): void {\n\tactiveSid = sid;\n}\n\n/** Snapshot of the active session's state; mutate via applyTodoMutation + commitTodoState. */\nexport function getTodoState(): TodoState {\n\treturn cloneState(states.get(activeSid) ?? EMPTY_TODO_STATE);\n}\n\nexport function replaceTodoState(next: TodoState): void {\n\tstates.set(activeSid, cloneState(next));\n}\n\nexport function commitTodoState(next: TodoState): void {\n\treplaceTodoState(next);\n}\n\n/** Drop in-memory state for a session (call from session_shutdown). */\nexport function disposeTodoSession(sid: string): void {\n\tif (!sid) return;\n\tstates.delete(sid);\n\tif (activeSid === sid) activeSid = \"\";\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction isPlainRecord(value: unknown): value is Record<string, unknown> {\n\tif (!isRecord(value)) return false;\n\tconst prototype = Object.getPrototypeOf(value);\n\treturn prototype === Object.prototype || prototype === null;\n}\n\nfunction isPositiveInteger(value: unknown): value is number {\n\treturn typeof value === \"number\" && Number.isSafeInteger(value) && value >= 1;\n}\n\nfunction isTodoStatus(value: unknown): value is TodoStatus {\n\treturn typeof value === \"string\" && TODO_STATUSES.has(value as TodoStatus);\n}\n\nfunction isTodoAction(value: unknown): value is TodoAction {\n\treturn typeof value === \"string\" && TODO_ACTIONS.has(value as TodoAction);\n}\n\nfunction jsonStringUtf8Bytes(value: string): number {\n\tlet bytes = 2; // JSON quotes\n\tfor (let index = 0; index < value.length; index++) {\n\t\tconst code = value.charCodeAt(index);\n\t\tif (\n\t\t\tcode === 0x22 ||\n\t\t\tcode === 0x5c ||\n\t\t\tcode === 0x08 ||\n\t\t\tcode === 0x0c ||\n\t\t\tcode === 0x0a ||\n\t\t\tcode === 0x0d ||\n\t\t\tcode === 0x09\n\t\t) {\n\t\t\tbytes += 2;\n\t\t\tcontinue;\n\t\t}\n\t\tif (code <= 0x1f) {\n\t\t\tbytes += 6;\n\t\t\tcontinue;\n\t\t}\n\t\tif (code >= 0xd800 && code <= 0xdbff) {\n\t\t\tconst next = value.charCodeAt(index + 1);\n\t\t\tif (next >= 0xdc00 && next <= 0xdfff) {\n\t\t\t\tbytes += 4;\n\t\t\t\tindex++;\n\t\t\t} else {\n\t\t\t\tbytes += 6; // JSON escapes lone surrogates.\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tif (code >= 0xdc00 && code <= 0xdfff) {\n\t\t\tbytes += 6;\n\t\t\tcontinue;\n\t\t}\n\t\tbytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;\n\t}\n\treturn bytes;\n}\n\nfunction addMetadataBytes(stats: MetadataStats, amount: number, limits: MetadataLimits): void {\n\tstats.bytes += amount;\n\tif (stats.bytes > limits.maxBytes) throw new Error(`metadata exceeds ${limits.maxBytes} UTF-8 bytes`);\n}\n\nfunction addMetadataEntry(stats: MetadataStats, limits: MetadataLimits): void {\n\tstats.entries++;\n\tif (stats.entries > limits.maxEntries) throw new Error(`metadata exceeds ${limits.maxEntries} entries`);\n}\n\n/**\n * Validate and clone JSON-like values without using JSON.stringify as the\n * validator. This rejects cycles, accessors, non-finite numbers, functions,\n * symbols, bigint, undefined, and non-plain object instances.\n */\nfunction cloneJsonValue(\n\tvalue: unknown,\n\tdepth: number,\n\tancestors: WeakSet<object>,\n\tstats: MetadataStats,\n\tlimits: MetadataLimits,\n): unknown {\n\tif (depth > limits.maxDepth) throw new Error(`metadata exceeds depth ${limits.maxDepth}`);\n\tif (value === null) {\n\t\taddMetadataBytes(stats, 4, limits);\n\t\treturn null;\n\t}\n\tif (typeof value === \"string\") {\n\t\taddMetadataBytes(stats, jsonStringUtf8Bytes(value), limits);\n\t\treturn value;\n\t}\n\tif (typeof value === \"boolean\") {\n\t\taddMetadataBytes(stats, value ? 4 : 5, limits);\n\t\treturn value;\n\t}\n\tif (typeof value === \"number\") {\n\t\tif (!Number.isFinite(value)) throw new Error(\"metadata numbers must be finite\");\n\t\taddMetadataBytes(stats, Buffer.byteLength(String(value), \"utf8\"), limits);\n\t\treturn value;\n\t}\n\tif (typeof value !== \"object\") throw new Error(\"metadata must contain only JSON-like values\");\n\tif (ancestors.has(value)) throw new Error(\"metadata cannot contain cycles\");\n\n\tancestors.add(value);\n\ttry {\n\t\tif (Array.isArray(value)) {\n\t\t\tif (value.length > limits.maxEntries - stats.entries) {\n\t\t\t\tthrow new Error(`metadata exceeds ${limits.maxEntries} entries`);\n\t\t\t}\n\t\t\tconst ownKeys = Object.keys(value);\n\t\t\tif (ownKeys.length !== value.length)\n\t\t\t\tthrow new Error(\"metadata arrays cannot be sparse or have extra properties\");\n\t\t\tconst copy: unknown[] = [];\n\t\t\taddMetadataBytes(stats, 2, limits);\n\t\t\tfor (let index = 0; index < value.length; index++) {\n\t\t\t\taddMetadataEntry(stats, limits);\n\t\t\t\tif (index > 0) addMetadataBytes(stats, 1, limits);\n\t\t\t\tconst descriptor = Object.getOwnPropertyDescriptor(value, String(index));\n\t\t\t\tif (!descriptor || !(\"value\" in descriptor)) throw new Error(\"metadata cannot contain accessors\");\n\t\t\t\tcopy.push(cloneJsonValue(descriptor.value, depth + 1, ancestors, stats, limits));\n\t\t\t}\n\t\t\treturn copy;\n\t\t}\n\n\t\tif (!isPlainRecord(value)) throw new Error(\"metadata objects must be plain objects\");\n\t\tconst copy: Record<string, unknown> = {};\n\t\tconst keys = Object.keys(value);\n\t\tif (keys.length > limits.maxEntries - stats.entries) {\n\t\t\tthrow new Error(`metadata exceeds ${limits.maxEntries} entries`);\n\t\t}\n\t\taddMetadataBytes(stats, 2, limits);\n\t\tfor (let index = 0; index < keys.length; index++) {\n\t\t\tconst key = keys[index];\n\t\t\tif (limits.maxKeyLength !== undefined && key.length > limits.maxKeyLength) {\n\t\t\t\tthrow new Error(`metadata keys exceed ${limits.maxKeyLength} characters`);\n\t\t\t}\n\t\t\tconst descriptor = Object.getOwnPropertyDescriptor(value, key);\n\t\t\tif (!descriptor || !(\"value\" in descriptor)) throw new Error(\"metadata cannot contain accessors\");\n\t\t\taddMetadataEntry(stats, limits);\n\t\t\tif (index > 0) addMetadataBytes(stats, 1, limits);\n\t\t\taddMetadataBytes(stats, jsonStringUtf8Bytes(key) + 1, limits);\n\t\t\tObject.defineProperty(copy, key, {\n\t\t\t\tvalue: cloneJsonValue(descriptor.value, depth + 1, ancestors, stats, limits),\n\t\t\t\tenumerable: true,\n\t\t\t\twritable: true,\n\t\t\t\tconfigurable: true,\n\t\t\t});\n\t\t}\n\t\treturn copy;\n\t} finally {\n\t\tancestors.delete(value);\n\t}\n}\n\nfunction validateAndCloneMetadata(value: unknown, limits: MetadataLimits): MetadataValidation {\n\tif (!isPlainRecord(value)) return { error: \"metadata must be a plain object\" };\n\ttry {\n\t\tconst cloned = cloneJsonValue(value, 1, new WeakSet<object>(), { entries: 0, bytes: 0 }, limits);\n\t\tif (!isPlainRecord(cloned)) return { error: \"metadata must be a plain object\" };\n\t\treturn { metadata: cloned };\n\t} catch (error) {\n\t\treturn { error: error instanceof Error ? error.message : \"metadata is invalid\" };\n\t}\n}\n\nfunction cloneStoredMetadata(value: Record<string, unknown>): Record<string, unknown> | undefined {\n\tconst result = validateAndCloneMetadata(value, LEGACY_METADATA_LIMITS);\n\treturn \"metadata\" in result ? result.metadata : undefined;\n}\n\nexport function cloneState(source: TodoState): TodoState {\n\treturn {\n\t\titems: source.items.map((item) => {\n\t\t\tconst clone: TodoItem = { ...item };\n\t\t\tif (item.blockedBy) clone.blockedBy = [...item.blockedBy];\n\t\t\telse delete clone.blockedBy;\n\t\t\tif (item.metadata) {\n\t\t\t\tconst metadata = cloneStoredMetadata(item.metadata);\n\t\t\t\tif (metadata) clone.metadata = metadata;\n\t\t\t\telse delete clone.metadata;\n\t\t\t} else delete clone.metadata;\n\t\t\treturn clone;\n\t\t}),\n\t\tnextId: source.nextId,\n\t};\n}\n\nfunction omitBlockedBy(item: TodoItem): TodoItem {\n\tconst next = { ...item };\n\tdelete next.blockedBy;\n\treturn next;\n}\n\n/**\n * Keep a tombstone while detaching its id from every dependent task. Also\n * reports which pending dependents ended up fully unblocked, so the tool\n * result can tell the model what became workable (mirrors the demote note).\n */\nfunction markDeleted(state: TodoState, id: number): { state: TodoState; releasedIds: number[] } {\n\tconst items = state.items.map((item) => {\n\t\tif (item.id === id) return { ...item, status: \"deleted\" as const };\n\t\tif (!item.blockedBy?.includes(id)) return item;\n\t\tconst blockedBy = item.blockedBy.filter((dependencyId) => dependencyId !== id);\n\t\treturn blockedBy.length ? { ...item, blockedBy } : omitBlockedBy(item);\n\t});\n\tconst nextState: TodoState = { items, nextId: state.nextId };\n\tconst releasedIds = state.items\n\t\t.filter((item) => item.id !== id && item.status === \"pending\" && item.blockedBy?.includes(id))\n\t\t.map((item) => item.id)\n\t\t.filter((dependentId) => {\n\t\t\tconst dependent = findItem(nextState, dependentId);\n\t\t\treturn dependent !== undefined && unresolvedDependencyIds(nextState, dependent).length === 0;\n\t\t});\n\treturn { state: nextState, releasedIds };\n}\n\nfunction error(state: TodoState, message: string): MutationResult {\n\treturn { state, operation: { kind: \"error\", message } };\n}\n\n// ---- per-action parameter validation ---------------------------------------\n// Parameters each action accepts. Anything else is rejected with guidance\n// instead of being silently ignored (a model that passes blockedBy on update\n// should learn about addBlockedBy, not lose the edit).\nconst ACTION_PARAMS: Record<TodoAction, ReadonlySet<string>> = {\n\tcreate: new Set([\"subject\", \"description\", \"activeForm\", \"status\", \"blockedBy\", \"owner\", \"metadata\"]),\n\tcreate_many: new Set([\"items\"]),\n\tupdate: new Set([\n\t\t\"id\",\n\t\t\"subject\",\n\t\t\"description\",\n\t\t\"activeForm\",\n\t\t\"status\",\n\t\t\"addBlockedBy\",\n\t\t\"removeBlockedBy\",\n\t\t\"addBlocks\",\n\t\t\"removeBlocks\",\n\t\t\"owner\",\n\t\t\"metadata\",\n\t]),\n\tlist: new Set([\"status\", \"includeDeleted\", \"limit\", \"afterId\", \"query\", \"unblockedOnly\"]),\n\tget: new Set([\"id\"]),\n\tdelete: new Set([\"id\"]),\n\tclear: new Set([\"confirm\", \"expectedCount\"]),\n};\n\nconst UPDATE_ONLY_EDGE_PARAMS = new Set([\"addBlockedBy\", \"removeBlockedBy\", \"addBlocks\", \"removeBlocks\"]);\n\nfunction inapplicableParamError(action: TodoAction, key: string): string {\n\tif (action === \"update\" && key === \"blockedBy\") {\n\t\treturn \"blockedBy is create-only; use addBlockedBy/removeBlockedBy on update\";\n\t}\n\tif (action === \"create\" && UPDATE_ONLY_EDGE_PARAMS.has(key)) {\n\t\treturn `${key} is update-only; use blockedBy on create`;\n\t}\n\treturn `${key} does not apply to action ${action}`;\n}\n\nfunction findInapplicableParam(params: TodoParams): string | undefined {\n\tconst allowed = ACTION_PARAMS[params.action];\n\tfor (const [key, value] of Object.entries(params)) {\n\t\tif (key === \"action\" || value === undefined) continue;\n\t\tif (!allowed.has(key)) return inapplicableParamError(params.action, key);\n\t}\n\treturn undefined;\n}\n\nfunction validateBoundedString(value: unknown, label: string, maximum: number): string | undefined {\n\tif (typeof value !== \"string\") return `${label} must be a string`;\n\tif (value.length > maximum) return `${label} exceeds ${maximum} characters`;\n\treturn undefined;\n}\n\nfunction validateTaskTextFields(params: TodoParams, requireDescription: boolean): string | undefined {\n\tif (params.subject !== undefined) {\n\t\tconst subjectError = validateBoundedString(params.subject, \"subject\", TODO_MAX_SUBJECT_LENGTH);\n\t\tif (subjectError) return subjectError;\n\t}\n\tif (params.description !== undefined) {\n\t\tconst descriptionError = validateBoundedString(params.description, \"description\", TODO_MAX_DESCRIPTION_LENGTH);\n\t\tif (descriptionError) return descriptionError;\n\t}\n\tif (params.activeForm !== undefined) {\n\t\tconst activeFormError = validateBoundedString(params.activeForm, \"activeForm\", TODO_MAX_ACTIVE_FORM_LENGTH);\n\t\tif (activeFormError) return activeFormError;\n\t}\n\tif (params.owner !== undefined) {\n\t\tconst ownerError = validateBoundedString(params.owner, \"owner\", TODO_MAX_OWNER_LENGTH);\n\t\tif (ownerError) return ownerError;\n\t}\n\tif (params.subject !== undefined && !params.subject.trim()) return \"subject cannot be empty\";\n\tif (requireDescription && !params.description?.trim()) {\n\t\treturn \"description required for create: state what done means for this task\";\n\t}\n\treturn undefined;\n}\n\nfunction validateIdArray(value: unknown, label: string): number[] | string {\n\tif (value === undefined) return [];\n\tif (!Array.isArray(value)) return `${label} must be an array`;\n\tif (value.length > TODO_MAX_BLOCKED_BY) return `${label} exceeds ${TODO_MAX_BLOCKED_BY} ids`;\n\tconst ids: number[] = [];\n\tfor (const id of value) {\n\t\tif (!isPositiveInteger(id)) return `${label} must contain positive integer ids`;\n\t\tif (!ids.includes(id)) ids.push(id);\n\t}\n\treturn ids;\n}\n\nfunction isTransitionAllowed(from: TodoStatus, to: TodoStatus): boolean {\n\t// `deleted` is the only terminal status; `completed` can be reopened to\n\t// in_progress or pending so a premature completion recovers without losing\n\t// the task id and its blockedBy edges.\n\tif (from === to) return true;\n\tif (from === \"deleted\") return false;\n\tif (to === \"deleted\") return true;\n\tif (to === \"completed\") return from === \"pending\" || from === \"in_progress\";\n\tif (to === \"pending\") return from === \"in_progress\" || from === \"completed\";\n\tif (to === \"in_progress\") return from === \"pending\" || from === \"completed\";\n\treturn false;\n}\n\nfunction findItem(state: TodoState, id: number): TodoItem | undefined {\n\treturn state.items.find((item) => item.id === id);\n}\n\nfunction validateDependencies(state: TodoState, deps: number[], currentId?: number): string | undefined {\n\tfor (const dep of deps) {\n\t\tif (dep === currentId) return `cannot block #${currentId} on itself`;\n\t\tconst item = findItem(state, dep);\n\t\tif (!item) return `blockedBy: #${dep} not found`;\n\t\tif (item.status === \"deleted\") return `blockedBy: #${dep} is deleted`;\n\t}\n\treturn undefined;\n}\n\n/** All listed dependencies must be completed before starting or closing a task. */\nfunction validateDependenciesReady(state: TodoState, deps: number[]): string | undefined {\n\tfor (const dep of deps) {\n\t\tconst item = findItem(state, dep);\n\t\tif (!item || item.status !== \"completed\") {\n\t\t\treturn `blockedBy: complete #${dep} before starting or finishing this task`;\n\t\t}\n\t}\n\treturn undefined;\n}\n\n/** IDs of dependencies that still prevent a task from starting or completing. */\nexport function unresolvedDependencyIds(state: TodoState, item: TodoItem): number[] {\n\treturn (item.blockedBy ?? []).filter((id) => findItem(state, id)?.status !== \"completed\");\n}\n\n/** True when every blockedBy dependency is completed (for transition and overlay checks). */\nexport function dependenciesSatisfied(state: TodoState, item: TodoItem): boolean {\n\treturn unresolvedDependencyIds(state, item).length === 0;\n}\n\n/** True when the resulting dependency graph contains a cycle. */\nfunction createsCycle(items: TodoItem[], nextDepsById: ReadonlyMap<number, number[]>): boolean {\n\tconst depsById = new Map<number, number[]>();\n\tfor (const item of items) depsById.set(item.id, item.blockedBy ?? []);\n\tfor (const [id, deps] of nextDepsById) depsById.set(id, deps);\n\n\t// An iterative color walk keeps replay and dependency edits safe for long,\n\t// valid chains that would overflow the JavaScript call stack recursively.\n\tconst colors = new Map<number, 0 | 1 | 2>();\n\tconst stack: Array<{ id: number; nextDependencyIndex: number }> = [];\n\tfor (const initialId of depsById.keys()) {\n\t\tif ((colors.get(initialId) ?? 0) !== 0) continue;\n\t\tcolors.set(initialId, 1);\n\t\tstack.push({ id: initialId, nextDependencyIndex: 0 });\n\n\t\twhile (stack.length) {\n\t\t\tconst frame = stack[stack.length - 1]!;\n\t\t\tconst dependencies = depsById.get(frame.id) ?? [];\n\t\t\tif (frame.nextDependencyIndex >= dependencies.length) {\n\t\t\t\tcolors.set(frame.id, 2);\n\t\t\t\tstack.pop();\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst dependencyId = dependencies[frame.nextDependencyIndex]!;\n\t\t\tframe.nextDependencyIndex++;\n\t\t\tif (!depsById.has(dependencyId)) continue;\n\t\t\tconst color = colors.get(dependencyId) ?? 0;\n\t\t\tif (color === 1) return true;\n\t\t\tif (color === 2) continue;\n\t\t\tcolors.set(dependencyId, 1);\n\t\t\tstack.push({ id: dependencyId, nextDependencyIndex: 0 });\n\t\t}\n\t}\n\treturn false;\n}\n\nfunction validateAndNormalizeBatch(params: TodoParams, state: TodoState): BatchItem[] | string {\n\tconst rawItems: unknown = (params as Record<string, unknown>).items;\n\tif (!Array.isArray(rawItems) || rawItems.length === 0) return \"items required for create_many\";\n\tif (rawItems.length > TODO_MAX_BATCH_ITEMS) return `items exceeds ${TODO_MAX_BATCH_ITEMS} tasks`;\n\n\tconst allowedKeys = new Set([\n\t\t\"key\",\n\t\t\"subject\",\n\t\t\"description\",\n\t\t\"activeForm\",\n\t\t\"blockedBy\",\n\t\t\"blockedByKeys\",\n\t\t\"owner\",\n\t\t\"metadata\",\n\t]);\n\tconst batch: BatchItem[] = [];\n\tconst keys = new Map<string, number>();\n\tfor (let index = 0; index < rawItems.length; index++) {\n\t\tconst raw = rawItems[index];\n\t\tif (!isPlainRecord(raw)) return `items[${index}] must be an object`;\n\t\tfor (const property of Object.keys(raw)) {\n\t\t\tif (!allowedKeys.has(property)) return `items[${index}].${property} does not apply to create_many`;\n\t\t}\n\t\tconst taskParams = raw as TodoParams;\n\t\tif (typeof raw.subject !== \"string\" || !raw.subject.trim())\n\t\t\treturn `items[${index}]: subject required for create_many`;\n\t\tconst taskTextError = validateTaskTextFields(taskParams, true);\n\t\tif (taskTextError) return `items[${index}]: ${taskTextError}`;\n\n\t\tlet key: string | undefined;\n\t\tif (raw.key !== undefined) {\n\t\t\tconst keyError = validateBoundedString(raw.key, \"key\", TODO_MAX_BATCH_KEY_LENGTH);\n\t\t\tif (keyError) return `items[${index}]: ${keyError}`;\n\t\t\tkey = (raw.key as string).trim();\n\t\t\tif (!key) return `items[${index}]: key cannot be empty`;\n\t\t\tif (keys.has(key)) return `items[${index}]: duplicate key ${key}`;\n\t\t\tkeys.set(key, index);\n\t\t}\n\n\t\tconst blockedBy = validateIdArray(raw.blockedBy, `items[${index}].blockedBy`);\n\t\tif (typeof blockedBy === \"string\") return blockedBy;\n\t\tconst blockedByError = validateDependencies(state, blockedBy);\n\t\tif (blockedByError) return `items[${index}]: ${blockedByError}`;\n\n\t\tconst rawBlockedByKeys = raw.blockedByKeys;\n\t\tif (rawBlockedByKeys !== undefined && !Array.isArray(rawBlockedByKeys)) {\n\t\t\treturn `items[${index}].blockedByKeys must be an array`;\n\t\t}\n\t\tif (Array.isArray(rawBlockedByKeys) && rawBlockedByKeys.length > TODO_MAX_BLOCKED_BY) {\n\t\t\treturn `items[${index}].blockedByKeys exceeds ${TODO_MAX_BLOCKED_BY} keys`;\n\t\t}\n\t\tconst blockedByKeys: string[] = [];\n\t\tfor (const reference of rawBlockedByKeys ?? []) {\n\t\t\tconst referenceError = validateBoundedString(reference, \"blockedByKeys\", TODO_MAX_BATCH_KEY_LENGTH);\n\t\t\tif (referenceError) return `items[${index}]: ${referenceError}`;\n\t\t\tconst normalizedReference = (reference as string).trim();\n\t\t\tif (!normalizedReference) return `items[${index}]: blockedByKeys cannot contain an empty key`;\n\t\t\tif (!blockedByKeys.includes(normalizedReference)) blockedByKeys.push(normalizedReference);\n\t\t}\n\n\t\tlet metadata: Record<string, unknown> | undefined;\n\t\tif (raw.metadata !== undefined) {\n\t\t\tconst metadataResult = validateAndCloneMetadata(raw.metadata, {\n\t\t\t\tmaxDepth: TODO_MAX_METADATA_DEPTH,\n\t\t\t\tmaxEntries: TODO_MAX_METADATA_ENTRIES,\n\t\t\t\tmaxBytes: TODO_MAX_METADATA_BYTES,\n\t\t\t\tmaxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,\n\t\t\t});\n\t\t\tif (\"error\" in metadataResult) return `items[${index}]: ${metadataResult.error}`;\n\t\t\tmetadata = metadataResult.metadata;\n\t\t}\n\n\t\tbatch.push({\n\t\t\t...(key ? { key } : {}),\n\t\t\tsubject: (raw.subject as string).trim(),\n\t\t\tdescription: raw.description as string,\n\t\t\t...(raw.activeForm ? { activeForm: raw.activeForm as string } : {}),\n\t\t\tblockedBy,\n\t\t\tblockedByKeys,\n\t\t\t...(raw.owner ? { owner: raw.owner as string } : {}),\n\t\t\t...(metadata ? { metadata } : {}),\n\t\t});\n\t}\n\n\tfor (let index = 0; index < batch.length; index++) {\n\t\tfor (const key of batch[index].blockedByKeys) {\n\t\t\tif (!keys.has(key)) return `items[${index}]: blockedByKeys references missing key ${key}`;\n\t\t\tif (key === batch[index].key) return `items[${index}]: cannot block a task on itself`;\n\t\t}\n\t}\n\treturn batch;\n}\n\nfunction createMany(state: TodoState, params: TodoParams): MutationResult {\n\tconst batch = validateAndNormalizeBatch(params, state);\n\tif (typeof batch === \"string\") return error(state, batch);\n\tif (\n\t\t!Number.isSafeInteger(state.nextId) ||\n\t\tstate.nextId < 1 ||\n\t\tstate.nextId + batch.length - 1 > Number.MAX_SAFE_INTEGER\n\t) {\n\t\treturn error(state, \"next id is invalid or exhausted\");\n\t}\n\n\tconst idsByKey = new Map<string, number>();\n\tfor (let index = 0; index < batch.length; index++) {\n\t\tconst key = batch[index].key;\n\t\tif (key) idsByKey.set(key, state.nextId + index);\n\t}\n\tconst created = batch.map((source, index) => {\n\t\tconst blockedBy = [...source.blockedBy];\n\t\tfor (const key of source.blockedByKeys) {\n\t\t\tconst id = idsByKey.get(key);\n\t\t\tif (id !== undefined && !blockedBy.includes(id)) blockedBy.push(id);\n\t\t}\n\t\tconst item: TodoItem = {\n\t\t\tid: state.nextId + index,\n\t\t\tsubject: source.subject,\n\t\t\tdescription: source.description,\n\t\t\tstatus: \"pending\",\n\t\t};\n\t\tif (source.activeForm) item.activeForm = source.activeForm;\n\t\tif (blockedBy.length) item.blockedBy = blockedBy;\n\t\tif (source.owner) item.owner = source.owner;\n\t\tif (source.metadata) item.metadata = source.metadata;\n\t\treturn item;\n\t});\n\tconst batchEdges = new Map<number, number[]>();\n\tfor (const item of created) batchEdges.set(item.id, item.blockedBy ?? []);\n\tif (createsCycle([...state.items, ...created], batchEdges)) return error(state, \"blockedBy would create a cycle\");\n\n\treturn {\n\t\tstate: { items: [...state.items, ...created], nextId: state.nextId + created.length },\n\t\toperation: { kind: \"create_many\", ids: created.map((item) => item.id) },\n\t};\n}\n\nexport function applyTodoMutation(input: TodoState, params: TodoParams): MutationResult {\n\tconst state = cloneState(input);\n\tif (!isTodoAction(params.action)) return error(state, `unknown action: ${String(params.action)}`);\n\n\tconst inapplicable = findInapplicableParam(params);\n\tif (inapplicable) return error(state, inapplicable);\n\n\tswitch (params.action) {\n\t\tcase \"create\": {\n\t\t\tif (typeof params.subject !== \"string\" || !params.subject.trim())\n\t\t\t\treturn error(state, \"subject required for create\");\n\t\t\tconst taskTextError = validateTaskTextFields(params, true);\n\t\t\tif (taskTextError) return error(state, taskTextError);\n\t\t\tif (params.status !== undefined && params.status !== \"pending\") {\n\t\t\t\treturn error(state, \"create always starts pending; use update to start or complete a task\");\n\t\t\t}\n\t\t\tconst blockedBy = validateIdArray(params.blockedBy, \"blockedBy\");\n\t\t\tif (typeof blockedBy === \"string\") return error(state, blockedBy);\n\t\t\tconst dependencyError = validateDependencies(state, blockedBy);\n\t\t\tif (dependencyError) return error(state, dependencyError);\n\n\t\t\tlet metadata: Record<string, unknown> | undefined;\n\t\t\tif (params.metadata !== undefined) {\n\t\t\t\tconst metadataResult = validateAndCloneMetadata(params.metadata, {\n\t\t\t\t\tmaxDepth: TODO_MAX_METADATA_DEPTH,\n\t\t\t\t\tmaxEntries: TODO_MAX_METADATA_ENTRIES,\n\t\t\t\t\tmaxBytes: TODO_MAX_METADATA_BYTES,\n\t\t\t\t\tmaxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,\n\t\t\t\t});\n\t\t\t\tif (\"error\" in metadataResult) return error(state, metadataResult.error);\n\t\t\t\tmetadata = metadataResult.metadata;\n\t\t\t}\n\n\t\t\tif (!Number.isSafeInteger(state.nextId) || state.nextId < 1 || state.nextId === Number.MAX_SAFE_INTEGER) {\n\t\t\t\treturn error(state, \"next id is invalid or exhausted\");\n\t\t\t}\n\t\t\tconst item: TodoItem = {\n\t\t\t\tid: state.nextId,\n\t\t\t\tsubject: (params.subject as string).trim(),\n\t\t\t\tstatus: \"pending\",\n\t\t\t\tdescription: params.description as string,\n\t\t\t};\n\t\t\tif (params.activeForm) item.activeForm = params.activeForm;\n\t\t\tif (blockedBy.length) item.blockedBy = blockedBy;\n\t\t\tif (params.owner) item.owner = params.owner;\n\t\t\tif (metadata) item.metadata = metadata;\n\n\t\t\treturn {\n\t\t\t\tstate: { items: [...state.items, item], nextId: state.nextId + 1 },\n\t\t\t\toperation: { kind: \"create\", id: item.id },\n\t\t\t};\n\t\t}\n\n\t\tcase \"create_many\":\n\t\t\treturn createMany(state, params);\n\n\t\tcase \"update\": {\n\t\t\tif (!isPositiveInteger(params.id)) return error(state, \"id required for update\");\n\t\t\tconst index = state.items.findIndex((item) => item.id === params.id);\n\t\t\tif (index === -1) return error(state, `#${params.id} not found`);\n\t\t\tconst current = state.items[index];\n\t\t\tif (current.status === \"deleted\") return error(state, `#${params.id} is deleted and cannot be updated`);\n\t\t\tconst taskTextError = validateTaskTextFields(params, false);\n\t\t\tif (taskTextError) return error(state, taskTextError);\n\t\t\tif (params.status !== undefined && !isTodoStatus(params.status)) return error(state, \"status is invalid\");\n\n\t\t\tconst addBlockedBy = validateIdArray(params.addBlockedBy, \"addBlockedBy\");\n\t\t\tconst removeBlockedBy = validateIdArray(params.removeBlockedBy, \"removeBlockedBy\");\n\t\t\tconst addBlocks = validateIdArray(params.addBlocks, \"addBlocks\");\n\t\t\tconst removeBlocks = validateIdArray(params.removeBlocks, \"removeBlocks\");\n\t\t\tif (typeof addBlockedBy === \"string\") return error(state, addBlockedBy);\n\t\t\tif (typeof removeBlockedBy === \"string\") return error(state, removeBlockedBy);\n\t\t\tif (typeof addBlocks === \"string\") return error(state, addBlocks);\n\t\t\tif (typeof removeBlocks === \"string\") return error(state, removeBlocks);\n\t\t\tconst edgeChange =\n\t\t\t\taddBlockedBy.length > 0 || removeBlockedBy.length > 0 || addBlocks.length > 0 || removeBlocks.length > 0;\n\t\t\tconst fieldChange =\n\t\t\t\tparams.subject !== undefined ||\n\t\t\t\tparams.description !== undefined ||\n\t\t\t\tparams.activeForm !== undefined ||\n\t\t\t\tparams.owner !== undefined ||\n\t\t\t\tparams.metadata !== undefined;\n\t\t\tif (!fieldChange && !edgeChange && params.status === undefined) {\n\t\t\t\treturn error(state, \"update requires at least one field\");\n\t\t\t}\n\n\t\t\tif (params.status === \"deleted\") {\n\t\t\t\tif (fieldChange || edgeChange) {\n\t\t\t\t\treturn error(\n\t\t\t\t\t\tstate,\n\t\t\t\t\t\t\"status deleted cannot be combined with other changes; apply edits first or delete in a separate call\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tconst deleted = markDeleted(state, current.id);\n\t\t\t\treturn {\n\t\t\t\t\tstate: deleted.state,\n\t\t\t\t\toperation: {\n\t\t\t\t\t\tkind: \"update\",\n\t\t\t\t\t\tid: current.id,\n\t\t\t\t\t\tfrom: current.status,\n\t\t\t\t\t\tto: \"deleted\",\n\t\t\t\t\t\t...(deleted.releasedIds.length ? { releasedIds: deleted.releasedIds } : {}),\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst nextStatus = params.status ?? current.status;\n\t\t\tif (!isTransitionAllowed(current.status, nextStatus)) {\n\t\t\t\treturn error(state, `illegal transition ${current.status} -> ${nextStatus}`);\n\t\t\t}\n\n\t\t\tlet metadata: Record<string, unknown> | undefined;\n\t\t\tif (params.metadata !== undefined) {\n\t\t\t\tconst patchResult = validateAndCloneMetadata(params.metadata, {\n\t\t\t\t\tmaxDepth: TODO_MAX_METADATA_DEPTH,\n\t\t\t\t\tmaxEntries: TODO_MAX_METADATA_ENTRIES,\n\t\t\t\t\tmaxBytes: TODO_MAX_METADATA_BYTES,\n\t\t\t\t\tmaxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,\n\t\t\t\t});\n\t\t\t\tif (\"error\" in patchResult) return error(state, patchResult.error);\n\t\t\t\tconst merged: Record<string, unknown> = Object.create(null) as Record<string, unknown>;\n\t\t\t\tfor (const [key, value] of Object.entries(current.metadata ?? {})) merged[key] = value;\n\t\t\t\tfor (const [key, value] of Object.entries(patchResult.metadata)) {\n\t\t\t\t\tif (value === null) delete merged[key];\n\t\t\t\t\telse merged[key] = value;\n\t\t\t\t}\n\t\t\t\tif (Object.keys(merged).length) {\n\t\t\t\t\tconst mergedResult = validateAndCloneMetadata(merged, {\n\t\t\t\t\t\tmaxDepth: TODO_MAX_METADATA_DEPTH,\n\t\t\t\t\t\tmaxEntries: TODO_MAX_METADATA_ENTRIES,\n\t\t\t\t\t\tmaxBytes: TODO_MAX_METADATA_BYTES,\n\t\t\t\t\t\tmaxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,\n\t\t\t\t\t});\n\t\t\t\t\tif (\"error\" in mergedResult) return error(state, mergedResult.error);\n\t\t\t\t\tmetadata = mergedResult.metadata;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Collect every task whose blockedBy changes: this task (addBlockedBy /\n\t\t\t// removeBlockedBy) plus reverse-edge targets (addBlocks / removeBlocks).\n\t\t\tconst nextDepsById = new Map<number, number[]>();\n\t\t\tlet nextDeps = current.blockedBy ? [...current.blockedBy] : [];\n\t\t\tif (removeBlockedBy.length) {\n\t\t\t\tconst remove = new Set(removeBlockedBy);\n\t\t\t\tnextDeps = nextDeps.filter((id) => !remove.has(id));\n\t\t\t}\n\t\t\tif (addBlockedBy.length) {\n\t\t\t\tconst dependencyError = validateDependencies(state, addBlockedBy, current.id);\n\t\t\t\tif (dependencyError) return error(state, dependencyError);\n\t\t\t\tfor (const dep of addBlockedBy) {\n\t\t\t\t\tif (!nextDeps.includes(dep)) nextDeps.push(dep);\n\t\t\t\t}\n\t\t\t}\n\t\t\tnextDepsById.set(current.id, nextDeps);\n\n\t\t\tif (removeBlocks.length) {\n\t\t\t\tconst release = new Set(removeBlocks);\n\t\t\t\tfor (const candidate of state.items) {\n\t\t\t\t\tif (!release.has(candidate.id) || !candidate.blockedBy?.includes(current.id)) continue;\n\t\t\t\t\tnextDepsById.set(\n\t\t\t\t\t\tcandidate.id,\n\t\t\t\t\t\tcandidate.blockedBy.filter((dependencyId) => dependencyId !== current.id),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (addBlocks.length) {\n\t\t\t\tfor (const targetId of addBlocks) {\n\t\t\t\t\tif (targetId === current.id) return error(state, `cannot block #${targetId} on itself`);\n\t\t\t\t\tconst target = findItem(state, targetId);\n\t\t\t\t\tif (!target) return error(state, `addBlocks: #${targetId} not found`);\n\t\t\t\t\tif (target.status === \"deleted\") return error(state, `addBlocks: #${targetId} is deleted`);\n\t\t\t\t\tconst deps = nextDepsById.get(targetId) ?? (target.blockedBy ? [...target.blockedBy] : []);\n\t\t\t\t\tif (!deps.includes(current.id)) deps.push(current.id);\n\t\t\t\t\tnextDepsById.set(targetId, deps);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (createsCycle(state.items, nextDepsById)) {\n\t\t\t\treturn error(state, \"blockedBy would create a cycle\");\n\t\t\t}\n\n\t\t\t// Readiness gates explicit status changes. An already active task that\n\t\t\t// takes on a new incomplete edge is instead automatically demoted below.\n\t\t\tif ((nextStatus === \"in_progress\" || nextStatus === \"completed\") && current.status !== nextStatus) {\n\t\t\t\tconst readyError = validateDependenciesReady(state, nextDeps);\n\t\t\t\tif (readyError) return error(state, readyError);\n\t\t\t}\n\n\t\t\tconst updated: TodoItem = { ...current, status: nextStatus };\n\t\t\tif (params.subject !== undefined) updated.subject = params.subject.trim();\n\t\t\t// Empty string removes an optional text field (metadata uses null keys).\n\t\t\tif (params.description !== undefined) {\n\t\t\t\tif (params.description) updated.description = params.description;\n\t\t\t\telse delete updated.description;\n\t\t\t}\n\t\t\tif (params.activeForm !== undefined) {\n\t\t\t\tif (params.activeForm) updated.activeForm = params.activeForm;\n\t\t\t\telse delete updated.activeForm;\n\t\t\t}\n\t\t\tif (params.owner !== undefined) {\n\t\t\t\tif (params.owner) updated.owner = params.owner;\n\t\t\t\telse delete updated.owner;\n\t\t\t}\n\t\t\tif (nextDeps.length) updated.blockedBy = nextDeps;\n\t\t\telse delete updated.blockedBy;\n\t\t\tif (params.metadata !== undefined) {\n\t\t\t\tif (metadata) updated.metadata = metadata;\n\t\t\t\telse delete updated.metadata;\n\t\t\t}\n\n\t\t\tconst items = state.items.map((task) => {\n\t\t\t\tif (task.id === current.id) return updated;\n\t\t\t\tconst deps = nextDepsById.get(task.id);\n\t\t\t\tif (deps === undefined) return task;\n\t\t\t\treturn deps.length ? { ...task, blockedBy: deps } : omitBlockedBy(task);\n\t\t\t});\n\n\t\t\t// A task that was already active may become newly blocked through either\n\t\t\t// direction of an edge edit. Keep it out of in_progress and distinguish\n\t\t\t// this from the usual single-active-task demotion in the result.\n\t\t\tconst blockedIds: number[] = [];\n\t\t\tfor (const [targetId, deps] of nextDepsById) {\n\t\t\t\tconst before = findItem(state, targetId);\n\t\t\t\tconst targetIndex = items.findIndex((item) => item.id === targetId);\n\t\t\t\tif (!before || targetIndex === -1 || items[targetIndex].status !== \"in_progress\") continue;\n\t\t\t\tconst previousDeps = before.blockedBy ?? [];\n\t\t\t\tconst gainedUnresolved = deps.some((dependencyId) => {\n\t\t\t\t\tif (previousDeps.includes(dependencyId)) return false;\n\t\t\t\t\tconst dependency = items.find((item) => item.id === dependencyId);\n\t\t\t\t\treturn dependency?.status !== \"completed\";\n\t\t\t\t});\n\t\t\t\tif (gainedUnresolved) {\n\t\t\t\t\titems[targetIndex] = { ...items[targetIndex], status: \"pending\" };\n\t\t\t\t\tblockedIds.push(targetId);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Exactly one in_progress: demote any other active tasks to pending.\n\t\t\tconst demotedIds: number[] = [];\n\t\t\tconst finalCurrent = items[index];\n\t\t\tif (nextStatus === \"in_progress\" && finalCurrent.status === \"in_progress\") {\n\t\t\t\tfor (let itemIndex = 0; itemIndex < items.length; itemIndex++) {\n\t\t\t\t\tconst item = items[itemIndex];\n\t\t\t\t\tif (item.id === finalCurrent.id || item.status !== \"in_progress\") continue;\n\t\t\t\t\titems[itemIndex] = { ...item, status: \"pending\" };\n\t\t\t\t\tdemotedIds.push(item.id);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst nextState: TodoState = { items, nextId: state.nextId };\n\t\t\tconst finalItem = items[index];\n\t\t\tconst newlyCompleted = current.status !== \"completed\" && finalItem.status === \"completed\";\n\t\t\treturn {\n\t\t\t\tstate: nextState,\n\t\t\t\toperation: {\n\t\t\t\t\tkind: \"update\",\n\t\t\t\t\tid: finalItem.id,\n\t\t\t\t\tfrom: current.status,\n\t\t\t\t\tto: finalItem.status,\n\t\t\t\t\t...(demotedIds.length ? { demotedIds } : {}),\n\t\t\t\t\t...(blockedIds.length ? { blockedIds } : {}),\n\t\t\t\t\t...(newlyCompleted && needsVerificationNudge(nextState) ? { verificationNudge: true } : {}),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tcase \"list\": {\n\t\t\tif (params.status !== undefined && !isTodoStatus(params.status)) return error(state, \"status is invalid\");\n\t\t\tif (params.includeDeleted !== undefined && typeof params.includeDeleted !== \"boolean\") {\n\t\t\t\treturn error(state, \"includeDeleted must be a boolean\");\n\t\t\t}\n\t\t\tif (params.unblockedOnly !== undefined && typeof params.unblockedOnly !== \"boolean\") {\n\t\t\t\treturn error(state, \"unblockedOnly must be a boolean\");\n\t\t\t}\n\t\t\tif (\n\t\t\t\tparams.limit !== undefined &&\n\t\t\t\t(!Number.isSafeInteger(params.limit) || params.limit < 1 || params.limit > TODO_MAX_LIST_LIMIT)\n\t\t\t) {\n\t\t\t\treturn error(state, `limit must be an integer from 1 to ${TODO_MAX_LIST_LIMIT}`);\n\t\t\t}\n\t\t\tif (params.afterId !== undefined && !isPositiveInteger(params.afterId))\n\t\t\t\treturn error(state, \"afterId must be a positive integer\");\n\t\t\tif (params.query !== undefined) {\n\t\t\t\tconst queryError = validateBoundedString(params.query, \"query\", TODO_MAX_LIST_QUERY_LENGTH);\n\t\t\t\tif (queryError) return error(state, queryError);\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tstate,\n\t\t\t\toperation: {\n\t\t\t\t\tkind: \"list\",\n\t\t\t\t\tstatus: params.status,\n\t\t\t\t\tincludeDeleted: params.includeDeleted === true,\n\t\t\t\t\tlimit: params.limit ?? LIST_DISPLAY_MAX_ITEMS,\n\t\t\t\t\t...(params.afterId !== undefined ? { afterId: params.afterId } : {}),\n\t\t\t\t\t...(params.query !== undefined ? { query: params.query } : {}),\n\t\t\t\t\tunblockedOnly: params.unblockedOnly === true,\n\t\t\t\t\tlegacyOutput:\n\t\t\t\t\t\tparams.limit === undefined &&\n\t\t\t\t\t\tparams.afterId === undefined &&\n\t\t\t\t\t\tparams.query === undefined &&\n\t\t\t\t\t\tparams.unblockedOnly === undefined,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tcase \"get\": {\n\t\t\tif (!isPositiveInteger(params.id)) return error(state, \"id required for get\");\n\t\t\tconst item = findItem(state, params.id);\n\t\t\tif (!item) return error(state, `#${params.id} not found`);\n\t\t\treturn { state, operation: { kind: \"get\", item } };\n\t\t}\n\n\t\tcase \"delete\": {\n\t\t\tif (!isPositiveInteger(params.id)) return error(state, \"id required for delete\");\n\t\t\tconst index = state.items.findIndex((item) => item.id === params.id);\n\t\t\tif (index === -1) return error(state, `#${params.id} not found`);\n\t\t\tconst current = state.items[index];\n\t\t\tif (current.status === \"deleted\") return error(state, `#${params.id} is already deleted`);\n\n\t\t\tconst deleted = markDeleted(state, current.id);\n\t\t\treturn {\n\t\t\t\tstate: deleted.state,\n\t\t\t\toperation: {\n\t\t\t\t\tkind: \"delete\",\n\t\t\t\t\tid: current.id,\n\t\t\t\t\tsubject: current.subject,\n\t\t\t\t\t...(deleted.releasedIds.length ? { releasedIds: deleted.releasedIds } : {}),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tcase \"clear\":\n\t\t\tif (params.confirm !== true) return error(state, \"clear requires confirm: true\");\n\t\t\tif (!Number.isSafeInteger(params.expectedCount) || params.expectedCount !== state.items.length) {\n\t\t\t\treturn error(state, `clear expectedCount must equal current task count (${state.items.length})`);\n\t\t\t}\n\t\t\treturn {\n\t\t\t\t// IDs remain monotonic across a clear so stale references cannot point\n\t\t\t\t// at an unrelated task created later on the same conversation branch.\n\t\t\t\tstate: { items: [], nextId: state.nextId },\n\t\t\t\toperation: { kind: \"clear\", count: state.items.length },\n\t\t\t};\n\t}\n}\n\nfunction isTodoStatusCounts(value: unknown): boolean {\n\tif (!isPlainRecord(value)) return false;\n\tfor (const [status, count] of Object.entries(value)) {\n\t\tif (!isTodoStatus(status) || typeof count !== \"number\" || !Number.isSafeInteger(count) || count < 0) return false;\n\t}\n\treturn true;\n}\n\nfunction isOperationSummary(value: unknown, action: TodoAction): value is TodoOperationSummary {\n\tif (!isPlainRecord(value) || value.kind !== action) return false;\n\tswitch (action) {\n\t\tcase \"create\":\n\t\tcase \"create_many\":\n\t\t\treturn Array.isArray(value.ids) && value.ids.every(isPositiveInteger);\n\t\tcase \"update\":\n\t\t\treturn isPositiveInteger(value.id) && isTodoStatus(value.status);\n\t\tcase \"list\":\n\t\t\treturn (\n\t\t\t\ttypeof value.includeDeleted === \"boolean\" &&\n\t\t\t\ttypeof value.unblockedOnly === \"boolean\" &&\n\t\t\t\t(value.status === undefined || isTodoStatus(value.status)) &&\n\t\t\t\t(value.limit === undefined ||\n\t\t\t\t\t(typeof value.limit === \"number\" && Number.isSafeInteger(value.limit) && value.limit >= 1)) &&\n\t\t\t\t(value.afterId === undefined || isPositiveInteger(value.afterId)) &&\n\t\t\t\t(value.query === undefined || typeof value.query === \"string\") &&\n\t\t\t\t(value.statusCounts === undefined || isTodoStatusCounts(value.statusCounts)) &&\n\t\t\t\t(value.resultCount === undefined ||\n\t\t\t\t\t(typeof value.resultCount === \"number\" &&\n\t\t\t\t\t\tNumber.isSafeInteger(value.resultCount) &&\n\t\t\t\t\t\tvalue.resultCount >= 0))\n\t\t\t);\n\t\tcase \"get\":\n\t\tcase \"delete\":\n\t\t\treturn isPositiveInteger(value.id);\n\t\tcase \"clear\":\n\t\t\treturn typeof value.count === \"number\" && Number.isSafeInteger(value.count) && value.count >= 0;\n\t}\n}\n\nfunction normalizeSnapshotState(itemsValue: unknown, nextIdValue: unknown): TodoState | undefined {\n\tif (!Array.isArray(itemsValue) || !isPositiveInteger(nextIdValue)) return undefined;\n\tconst items: TodoItem[] = [];\n\tconst ids = new Set<number>();\n\tconst itemsById = new Map<number, TodoItem>();\n\tfor (const rawItem of itemsValue) {\n\t\tif (!isPlainRecord(rawItem) || !isPositiveInteger(rawItem.id) || ids.has(rawItem.id)) return undefined;\n\t\tif (typeof rawItem.subject !== \"string\" || !isTodoStatus(rawItem.status)) return undefined;\n\t\tif (rawItem.description !== undefined && typeof rawItem.description !== \"string\") return undefined;\n\t\tif (rawItem.activeForm !== undefined && typeof rawItem.activeForm !== \"string\") return undefined;\n\t\tif (rawItem.owner !== undefined && typeof rawItem.owner !== \"string\") return undefined;\n\t\tlet blockedBy: number[] | undefined;\n\t\tif (rawItem.blockedBy !== undefined) {\n\t\t\tif (!Array.isArray(rawItem.blockedBy) || !rawItem.blockedBy.every(isPositiveInteger)) return undefined;\n\t\t\tblockedBy = Array.from(new Set(rawItem.blockedBy));\n\t\t}\n\t\tlet metadata: Record<string, unknown> | undefined;\n\t\tif (rawItem.metadata !== undefined) {\n\t\t\tconst metadataResult = validateAndCloneMetadata(rawItem.metadata, LEGACY_METADATA_LIMITS);\n\t\t\tif (\"error\" in metadataResult) return undefined;\n\t\t\tmetadata = metadataResult.metadata;\n\t\t}\n\t\tconst item: TodoItem = { id: rawItem.id, subject: rawItem.subject, status: rawItem.status };\n\t\tif (rawItem.description !== undefined) item.description = rawItem.description;\n\t\tif (rawItem.activeForm !== undefined) item.activeForm = rawItem.activeForm;\n\t\tif (blockedBy?.length) item.blockedBy = blockedBy;\n\t\tif (rawItem.owner !== undefined) item.owner = rawItem.owner;\n\t\tif (metadata) item.metadata = metadata;\n\t\titems.push(item);\n\t\titemsById.set(item.id, item);\n\t\tids.add(item.id);\n\t}\n\tconst highestId = items.reduce((highest, item) => Math.max(highest, item.id), 0);\n\tif (nextIdValue <= highestId) return undefined;\n\tfor (const item of items) {\n\t\tfor (const dependencyId of item.blockedBy ?? []) {\n\t\t\tconst dependency = itemsById.get(dependencyId);\n\t\t\tif (!dependency || dependency.status === \"deleted\") return undefined;\n\t\t}\n\t}\n\tif (createsCycle(items, new Map<number, number[]>())) return undefined;\n\n\t// Legacy snapshots could contain the formerly permitted \"active but newly\n\t// blocked\" state. Normalize it to the current invariant rather than losing\n\t// the newest otherwise-valid task list during replay.\n\tfor (const item of items) {\n\t\tif (item.status !== \"in_progress\") continue;\n\t\tif ((item.blockedBy ?? []).some((id) => itemsById.get(id)?.status !== \"completed\")) {\n\t\t\titem.status = \"pending\";\n\t\t}\n\t}\n\tif (items.filter((item) => item.status === \"in_progress\").length > 1) return undefined;\n\titems.sort((first, second) => first.id - second.id);\n\treturn { items, nextId: nextIdValue };\n}\n\nfunction normalizeDetails(value: unknown): TodoState | undefined {\n\tif (!isPlainRecord(value) || !isTodoAction(value.action) || !isPlainRecord(value.params)) return undefined;\n\tif (value.schemaVersion !== undefined) {\n\t\tif (value.schemaVersion !== TODO_DETAILS_SCHEMA_VERSION || !isOperationSummary(value.operation, value.action)) {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\treturn normalizeSnapshotState(value.items, value.nextId);\n}\n\nexport function replayTodosFromBranch(ctx: { sessionManager: { getBranch(): Iterable<unknown> } }): TodoState {\n\t// Latest valid todo toolResult wins; scan tail → head so malformed latest\n\t// snapshots cannot poison a branch that still has an earlier valid state.\n\tconst branch = Array.from(ctx.sessionManager.getBranch());\n\tfor (let index = branch.length - 1; index >= 0; index--) {\n\t\tconst entry = branch[index] as {\n\t\t\ttype?: string;\n\t\t\tmessage?: { role?: string; toolName?: string; details?: unknown };\n\t\t};\n\t\tif (entry.type !== \"message\") continue;\n\t\tif (entry.message?.role !== \"toolResult\" || entry.message.toolName !== TODO_TOOL_NAME) continue;\n\t\tlet state: TodoState | undefined;\n\t\ttry {\n\t\t\tstate = normalizeDetails(entry.message.details);\n\t\t} catch {\n\t\t\t// Session history is external input; ignore hostile or malformed details.\n\t\t\tcontinue;\n\t\t}\n\t\tif (state) return cloneState(state);\n\t}\n\treturn cloneState(EMPTY_TODO_STATE);\n}\n\nfunction cloneDetailsParams(params: TodoParams): Record<string, unknown> {\n\tconst copy: Record<string, unknown> = {};\n\tfor (const [key, value] of Object.entries(params)) {\n\t\tif (value === undefined) continue;\n\t\tif (key === \"metadata\") {\n\t\t\tconst metadata = validateAndCloneMetadata(value, LEGACY_METADATA_LIMITS);\n\t\t\tif (\"metadata\" in metadata) copy[key] = metadata.metadata;\n\t\t\tcontinue;\n\t\t}\n\t\tif (key === \"items\" && Array.isArray(value)) {\n\t\t\tcopy[key] = value.map((item) => {\n\t\t\t\tif (!isPlainRecord(item)) return item;\n\t\t\t\tconst itemCopy: Record<string, unknown> = { ...item };\n\t\t\t\tif (item.metadata !== undefined) {\n\t\t\t\t\tconst metadata = validateAndCloneMetadata(item.metadata, LEGACY_METADATA_LIMITS);\n\t\t\t\t\tif (\"metadata\" in metadata) itemCopy.metadata = metadata.metadata;\n\t\t\t\t}\n\t\t\t\treturn itemCopy;\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tcopy[key] = value;\n\t}\n\treturn copy;\n}\n\nfunction buildTodoOperationSummary(params: TodoParams, next: TodoState): TodoOperationSummary {\n\tswitch (params.action) {\n\t\tcase \"create\":\n\t\t\treturn { kind: \"create\", ids: [next.nextId - 1] };\n\t\tcase \"create_many\": {\n\t\t\tconst count = Array.isArray(params.items) ? params.items.length : 0;\n\t\t\tconst firstId = next.nextId - count;\n\t\t\treturn { kind: \"create_many\", ids: Array.from({ length: count }, (_, index) => firstId + index) };\n\t\t}\n\t\tcase \"update\": {\n\t\t\tconst item = findItem(next, params.id as number);\n\t\t\treturn { kind: \"update\", id: params.id as number, status: item?.status ?? \"pending\" };\n\t\t}\n\t\tcase \"list\": {\n\t\t\tconst listOperation: Extract<Operation, { kind: \"list\" }> = {\n\t\t\t\tkind: \"list\",\n\t\t\t\t...(params.status !== undefined ? { status: params.status } : {}),\n\t\t\t\tincludeDeleted: params.includeDeleted === true,\n\t\t\t\tlimit: params.limit ?? LIST_DISPLAY_MAX_ITEMS,\n\t\t\t\t...(params.afterId !== undefined ? { afterId: params.afterId } : {}),\n\t\t\t\t...(params.query !== undefined ? { query: params.query } : {}),\n\t\t\t\tunblockedOnly: params.unblockedOnly === true,\n\t\t\t\tlegacyOutput: false,\n\t\t\t};\n\t\t\tconst shown = getListView(listOperation, next).slice(0, listOperation.limit);\n\t\t\tconst statusCounts: Partial<Record<TodoStatus, number>> = {};\n\t\t\tfor (const item of shown) statusCounts[item.status] = (statusCounts[item.status] ?? 0) + 1;\n\t\t\treturn {\n\t\t\t\tkind: \"list\",\n\t\t\t\t...(listOperation.status !== undefined ? { status: listOperation.status } : {}),\n\t\t\t\tincludeDeleted: listOperation.includeDeleted,\n\t\t\t\tlimit: listOperation.limit,\n\t\t\t\t...(listOperation.afterId !== undefined ? { afterId: listOperation.afterId } : {}),\n\t\t\t\t...(listOperation.query !== undefined ? { query: listOperation.query } : {}),\n\t\t\t\tunblockedOnly: listOperation.unblockedOnly,\n\t\t\t\tstatusCounts,\n\t\t\t\tresultCount: shown.length,\n\t\t\t};\n\t\t}\n\t\tcase \"get\":\n\t\t\treturn { kind: \"get\", id: params.id as number };\n\t\tcase \"delete\":\n\t\t\treturn { kind: \"delete\", id: params.id as number };\n\t\tcase \"clear\":\n\t\t\treturn { kind: \"clear\", count: params.expectedCount as number };\n\t}\n}\n\nexport function buildTodoDetails(params: TodoParams, next: TodoState): TodoDetails {\n\tconst snapshot = cloneState(next);\n\treturn {\n\t\tschemaVersion: TODO_DETAILS_SCHEMA_VERSION,\n\t\taction: params.action,\n\t\tparams: cloneDetailsParams(params),\n\t\toperation: buildTodoOperationSummary(params, snapshot),\n\t\titems: snapshot.items,\n\t\tnextId: snapshot.nextId,\n\t};\n}\n\nconst VERIFICATION_NUDGE_NOTE =\n\t\"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.\";\n\nfunction formatReleasedSuffix(releasedIds: number[] | undefined): string {\n\tif (!releasedIds?.length) return \"\";\n\treturn `; released ${releasedIds.map((id) => `#${id}`).join(\",\")} (no longer blocked)`;\n}\n\nfunction formatDisplayText(value: string, maximum: number): string {\n\treturn value.length > maximum ? `${value.slice(0, maximum)}…` : value;\n}\n\nfunction getListView(operation: Extract<Operation, { kind: \"list\" }>, state: TodoState): TodoItem[] {\n\tlet view = state.items;\n\t// An explicit deleted-status query is sufficient intent to include tombstones.\n\tif (!operation.includeDeleted && operation.status !== \"deleted\") {\n\t\tview = view.filter((item) => item.status !== \"deleted\");\n\t}\n\tif (operation.status) view = view.filter((item) => item.status === operation.status);\n\tif (operation.afterId !== undefined) {\n\t\tconst afterId = operation.afterId;\n\t\tview = view.filter((item) => item.id > afterId);\n\t}\n\tif (operation.query !== undefined) {\n\t\tconst query = operation.query.toLocaleLowerCase();\n\t\tview = view.filter(\n\t\t\t(item) =>\n\t\t\t\titem.subject.toLocaleLowerCase().includes(query) || item.description?.toLocaleLowerCase().includes(query),\n\t\t);\n\t}\n\tif (operation.unblockedOnly) view = view.filter((item) => dependenciesSatisfied(state, item));\n\treturn [...view].sort((first, second) => first.id - second.id);\n}\n\nexport function formatTodoContent(operation: Operation, state: TodoState): string {\n\tswitch (operation.kind) {\n\t\tcase \"create\": {\n\t\t\tconst item = findItem(state, operation.id);\n\t\t\treturn item\n\t\t\t\t? `Created #${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (pending)`\n\t\t\t\t: `Created #${operation.id}`;\n\t\t}\n\t\tcase \"create_many\":\n\t\t\treturn `Created ${operation.ids.length} tasks: ${operation.ids.map((id) => `#${id}`).join(\", \")}`;\n\t\tcase \"update\": {\n\t\t\tconst transition = operation.from === operation.to ? \"\" : ` (${operation.from} -> ${operation.to})`;\n\t\t\tlet text = `Updated #${operation.id}${transition}`;\n\t\t\tif (operation.demotedIds?.length) {\n\t\t\t\ttext += `; ${operation.demotedIds.map((id) => `#${id}`).join(\",\")} demoted to pending`;\n\t\t\t}\n\t\t\tif (operation.blockedIds?.length) {\n\t\t\t\ttext += `; ${operation.blockedIds.map((id) => `#${id}`).join(\",\")} moved to pending (dependencies incomplete)`;\n\t\t\t}\n\t\t\ttext += formatReleasedSuffix(operation.releasedIds);\n\t\t\tif (operation.verificationNudge) text += `\\n\\n${VERIFICATION_NUDGE_NOTE}`;\n\t\t\treturn text;\n\t\t}\n\t\tcase \"delete\":\n\t\t\treturn `Deleted #${operation.id}: ${formatDisplayText(operation.subject, TODO_MAX_SUBJECT_LENGTH)}${formatReleasedSuffix(operation.releasedIds)}`;\n\t\tcase \"clear\":\n\t\t\treturn `Cleared ${operation.count} tasks`;\n\t\tcase \"list\":\n\t\t\treturn formatBoundedList(getListView(operation, state), state, operation);\n\t\tcase \"get\":\n\t\t\treturn formatDetailItem(operation.item, state);\n\t\tcase \"error\":\n\t\t\treturn `Error: ${operation.message}`;\n\t}\n}\n\n/**\n * Soft nudge when the whole list is done (no active work), there are 3+ completed\n * tasks, and none look like a verification/test/review step — mirrors CC TodoWrite.\n */\nfunction needsVerificationNudge(state: TodoState): boolean {\n\tconst active = state.items.filter((item) => item.status !== \"deleted\");\n\tif (active.length < 3) return false;\n\tif (active.some((item) => item.status === \"pending\" || item.status === \"in_progress\")) return false;\n\tconst completed = active.filter((item) => item.status === \"completed\");\n\tif (completed.length < 3) return false;\n\treturn !completed.some(\n\t\t(item) =>\n\t\t\tVERIFICATION_PATTERN.test(item.subject) ||\n\t\t\t(item.description !== undefined && VERIFICATION_PATTERN.test(item.description)),\n\t);\n}\n\nfunction formatBoundedList(\n\tview: TodoItem[],\n\tstate: TodoState,\n\toperation: Extract<Operation, { kind: \"list\" }>,\n): string {\n\tif (!view.length) return \"No tasks\";\n\tconst shown = view.slice(0, operation.limit);\n\tconst lines = shown.map((item) => formatListItem(item, state));\n\tconst omitted = view.length - shown.length;\n\tif (omitted > 0) {\n\t\tif (!operation.legacyOutput) {\n\t\t\tlines.push(`… and ${omitted} more; next page: afterId=${shown.at(-1)?.id}.`);\n\t\t} else {\n\t\t\t// Do not suggest a filter that is already applied.\n\t\t\tconst hint = operation.status ? \"use get with id= for details\" : \"narrow with status= or use get with id=\";\n\t\t\tlines.push(`… and ${omitted} more (${shown.length} of ${view.length} shown); ${hint}.`);\n\t\t}\n\t}\n\treturn lines.join(\"\\n\");\n}\n\nfunction formatListIdList(ids: number[]): string {\n\tconst shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);\n\treturn ids.length > shown.length ? `${shown.join(\",\")},…+${ids.length - shown.length}` : shown.join(\",\");\n}\n\nfunction formatListItem(item: TodoItem, state: TodoState): string {\n\tconst unresolved = unresolvedDependencyIds(state, item);\n\tconst deps = unresolved.length ? ` blockedBy=${formatListIdList(unresolved)}` : \"\";\n\tconst owner = item.owner ? ` @${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}` : \"\";\n\tconst active =\n\t\titem.status === \"in_progress\" && item.activeForm\n\t\t\t? ` (${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)})`\n\t\t\t: \"\";\n\treturn `[${item.status}] #${item.id} ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}${active}${owner}${deps}`;\n}\n\nfunction formatIdList(ids: number[]): string {\n\tconst shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);\n\treturn ids.length > shown.length ? `${shown.join(\", \")}, … +${ids.length - shown.length}` : shown.join(\", \");\n}\n\nfunction formatDetailItem(item: TodoItem, state: TodoState): string {\n\tconst lines = [`#${item.id} [${item.status}] ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}`];\n\tif (item.description) lines.push(`description: ${formatDisplayText(item.description, TODO_MAX_DESCRIPTION_LENGTH)}`);\n\tif (item.activeForm) lines.push(`activeForm: ${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)}`);\n\tif (item.blockedBy?.length) lines.push(`blockedBy: ${formatIdList(item.blockedBy)}`);\n\tconst blocks = state.items\n\t\t.filter((candidate) => candidate.blockedBy?.includes(item.id))\n\t\t.map((candidate) => candidate.id);\n\tif (blocks.length) lines.push(`blocks: ${formatIdList(blocks)}`);\n\tif (item.owner) lines.push(`owner: ${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}`);\n\treturn lines.join(\"\\n\");\n}\n"]}
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../../src/extensions/todo/state.ts"],"names":[],"mappings":"AAiBA,OAAO,EAIN,KAAK,WAAW,EAChB,KAAK,QAAQ,EAEb,KAAK,UAAU,EACf,KAAK,SAAS,EACd,KAAK,UAAU,EACf,MAAM,aAAa,CAAC;AAErB,KAAK,SAAS,GACX;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,GAAG,EAAE,MAAM,EAAE,CAAA;CAAE,GACtC;IACA,IAAI,EAAE,QAAQ,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,UAAU,CAAC;IACjB,EAAE,EAAE,UAAU,CAAC;IACf,wFAAsF;IACtF,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,iFAAiF;IACjF,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC3B,GACD;IACA,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;IACvB,uDAAuD;IACvD,YAAY,EAAE,OAAO,CAAC;CACrB,GACD;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GACvE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAqDtC,MAAM,WAAW,cAAc;IAC9B,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,SAAS,CAAC;CACrB;AAWD,kFAAkF;AAClF,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAEtD;AAED,8FAA8F;AAC9F,wBAAgB,YAAY,IAAI,SAAS,CAExC;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAEtD;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAErD;AAED,uEAAuE;AACvE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAIpD;AA6KD,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,GAAG,SAAS,CAevD;AAkKD,iFAAiF;AACjF,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,QAAQ,GAAG,MAAM,EAAE,CAElF;AAED,6FAA6F;AAC7F,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,QAAQ,GAAG,OAAO,CAE/E;AAgLD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,GAAG,cAAc,CAsVtF;AA2GD,wBAAgB,qBAAqB,CAAC,GAAG,EAAE;IAAE,cAAc,EAAE;QAAE,SAAS,IAAI,QAAQ,CAAC,OAAO,CAAC,CAAA;KAAE,CAAA;CAAE,GAAG,SAAS,CAqB5G;AA4ED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,GAAG,WAAW,CAUjF;AAoCD,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,GAAG,MAAM,CA0ChF","sourcesContent":["import {\n\tLIST_DISPLAY_MAX_ITEMS,\n\tTODO_MAX_ACTIVE_FORM_LENGTH,\n\tTODO_MAX_BATCH_ITEMS,\n\tTODO_MAX_BATCH_KEY_LENGTH,\n\tTODO_MAX_BLOCKED_BY,\n\tTODO_MAX_DESCRIPTION_LENGTH,\n\tTODO_MAX_LIST_LIMIT,\n\tTODO_MAX_LIST_QUERY_LENGTH,\n\tTODO_MAX_METADATA_BYTES,\n\tTODO_MAX_METADATA_DEPTH,\n\tTODO_MAX_METADATA_ENTRIES,\n\tTODO_MAX_METADATA_KEY_LENGTH,\n\tTODO_MAX_OWNER_LENGTH,\n\tTODO_MAX_SUBJECT_LENGTH,\n\tTODO_TOOL_NAME,\n} from \"./constants.ts\";\nimport {\n\tEMPTY_TODO_STATE,\n\tTODO_DETAILS_SCHEMA_VERSION,\n\ttype TodoAction,\n\ttype TodoDetails,\n\ttype TodoItem,\n\ttype TodoOperationSummary,\n\ttype TodoParams,\n\ttype TodoState,\n\ttype TodoStatus,\n} from \"./schema.ts\";\n\ntype Operation =\n\t| { kind: \"create\"; id: number }\n\t| { kind: \"create_many\"; ids: number[] }\n\t| {\n\t\t\tkind: \"update\";\n\t\t\tid: number;\n\t\t\tfrom: TodoStatus;\n\t\t\tto: TodoStatus;\n\t\t\t/** Other tasks auto-demoted from in_progress → pending to keep exactly one active. */\n\t\t\tdemotedIds?: number[];\n\t\t\t/** Active tasks demoted because a new dependency is still incomplete. */\n\t\t\tblockedIds?: number[];\n\t\t\t/** Pending dependents left fully unblocked by an update to status deleted. */\n\t\t\treleasedIds?: number[];\n\t\t\t/** Soft note when the list is fully closed without a verification-style task. */\n\t\t\tverificationNudge?: boolean;\n\t }\n\t| {\n\t\t\tkind: \"list\";\n\t\t\tstatus?: TodoStatus;\n\t\t\tincludeDeleted: boolean;\n\t\t\tlimit: number;\n\t\t\tafterId?: number;\n\t\t\tquery?: string;\n\t\t\tunblockedOnly: boolean;\n\t\t\t/** Keep the historical no-options list text stable. */\n\t\t\tlegacyOutput: boolean;\n\t }\n\t| { kind: \"get\"; item: TodoItem }\n\t| { kind: \"delete\"; id: number; subject: string; releasedIds?: number[] }\n\t| { kind: \"clear\"; count: number }\n\t| { kind: \"error\"; message: string };\n\n/** Subjects/descriptions that count as a verification step (CC-style soft nudge). */\nconst VERIFICATION_PATTERN = /verif|test|check|review/i;\nconst TODO_STATUSES: ReadonlySet<TodoStatus> = new Set([\"pending\", \"in_progress\", \"completed\", \"deleted\"]);\nconst TODO_ACTIONS: ReadonlySet<TodoAction> = new Set([\n\t\"create\",\n\t\"create_many\",\n\t\"update\",\n\t\"list\",\n\t\"get\",\n\t\"delete\",\n\t\"clear\",\n]);\nconst LEGACY_METADATA_LIMITS = {\n\tmaxDepth: 64,\n\tmaxEntries: 100_000,\n\tmaxBytes: 16 * 1024 * 1024,\n};\n\ninterface MetadataLimits {\n\tmaxDepth: number;\n\tmaxEntries: number;\n\tmaxBytes: number;\n\tmaxKeyLength?: number;\n}\n\ninterface MetadataStats {\n\tentries: number;\n\tbytes: number;\n}\n\ninterface ValidMetadata {\n\tmetadata: Record<string, unknown>;\n}\n\ninterface InvalidMetadata {\n\terror: string;\n}\n\ntype MetadataValidation = ValidMetadata | InvalidMetadata;\n\ninterface BatchItem {\n\tkey?: string;\n\tsubject: string;\n\tdescription: string;\n\tactiveForm?: string;\n\tblockedBy: number[];\n\tblockedByKeys: string[];\n\towner?: string;\n\tmetadata?: Record<string, unknown>;\n}\n\nexport interface MutationResult {\n\tstate: TodoState;\n\toperation: Operation;\n}\n\n// ---- per-session state ------------------------------------------------------\n// Keyed by session id like rewind's engine state: one Pi process can host more\n// than one session over its lifetime (resume, /tree branch switches), and a\n// module-level singleton would leak one session's list into another. Tool\n// renderers get no ctx, so the active session is a module-level pointer kept\n// current by execute and the lifecycle handlers (which do have ctx).\nconst states = new Map<string, TodoState>();\nlet activeSid = \"\";\n\n/** Point the module at a session's state bucket. Call wherever ctx is in hand. */\nexport function setActiveTodoSession(sid: string): void {\n\tactiveSid = sid;\n}\n\n/** Snapshot of the active session's state; mutate via applyTodoMutation + commitTodoState. */\nexport function getTodoState(): TodoState {\n\treturn cloneState(states.get(activeSid) ?? EMPTY_TODO_STATE);\n}\n\nexport function replaceTodoState(next: TodoState): void {\n\tstates.set(activeSid, cloneState(next));\n}\n\nexport function commitTodoState(next: TodoState): void {\n\treplaceTodoState(next);\n}\n\n/** Drop in-memory state for a session (call from session_shutdown). */\nexport function disposeTodoSession(sid: string): void {\n\tif (!sid) return;\n\tstates.delete(sid);\n\tif (activeSid === sid) activeSid = \"\";\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction isPlainRecord(value: unknown): value is Record<string, unknown> {\n\tif (!isRecord(value)) return false;\n\tconst prototype = Object.getPrototypeOf(value);\n\treturn prototype === Object.prototype || prototype === null;\n}\n\nfunction isPositiveInteger(value: unknown): value is number {\n\treturn typeof value === \"number\" && Number.isSafeInteger(value) && value >= 1;\n}\n\nfunction isTodoStatus(value: unknown): value is TodoStatus {\n\treturn typeof value === \"string\" && TODO_STATUSES.has(value as TodoStatus);\n}\n\nfunction isTodoAction(value: unknown): value is TodoAction {\n\treturn typeof value === \"string\" && TODO_ACTIONS.has(value as TodoAction);\n}\n\nfunction jsonStringUtf8Bytes(value: string): number {\n\tlet bytes = 2; // JSON quotes\n\tfor (let index = 0; index < value.length; index++) {\n\t\tconst code = value.charCodeAt(index);\n\t\tif (\n\t\t\tcode === 0x22 ||\n\t\t\tcode === 0x5c ||\n\t\t\tcode === 0x08 ||\n\t\t\tcode === 0x0c ||\n\t\t\tcode === 0x0a ||\n\t\t\tcode === 0x0d ||\n\t\t\tcode === 0x09\n\t\t) {\n\t\t\tbytes += 2;\n\t\t\tcontinue;\n\t\t}\n\t\tif (code <= 0x1f) {\n\t\t\tbytes += 6;\n\t\t\tcontinue;\n\t\t}\n\t\tif (code >= 0xd800 && code <= 0xdbff) {\n\t\t\tconst next = value.charCodeAt(index + 1);\n\t\t\tif (next >= 0xdc00 && next <= 0xdfff) {\n\t\t\t\tbytes += 4;\n\t\t\t\tindex++;\n\t\t\t} else {\n\t\t\t\tbytes += 6; // JSON escapes lone surrogates.\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tif (code >= 0xdc00 && code <= 0xdfff) {\n\t\t\tbytes += 6;\n\t\t\tcontinue;\n\t\t}\n\t\tbytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;\n\t}\n\treturn bytes;\n}\n\nfunction addMetadataBytes(stats: MetadataStats, amount: number, limits: MetadataLimits): void {\n\tstats.bytes += amount;\n\tif (stats.bytes > limits.maxBytes) throw new Error(`metadata exceeds ${limits.maxBytes} UTF-8 bytes`);\n}\n\nfunction addMetadataEntry(stats: MetadataStats, limits: MetadataLimits): void {\n\tstats.entries++;\n\tif (stats.entries > limits.maxEntries) throw new Error(`metadata exceeds ${limits.maxEntries} entries`);\n}\n\n/**\n * Validate and clone JSON-like values without using JSON.stringify as the\n * validator. This rejects cycles, accessors, non-finite numbers, functions,\n * symbols, bigint, undefined, and non-plain object instances.\n */\nfunction cloneJsonValue(\n\tvalue: unknown,\n\tdepth: number,\n\tancestors: WeakSet<object>,\n\tstats: MetadataStats,\n\tlimits: MetadataLimits,\n): unknown {\n\tif (depth > limits.maxDepth) throw new Error(`metadata exceeds depth ${limits.maxDepth}`);\n\tif (value === null) {\n\t\taddMetadataBytes(stats, 4, limits);\n\t\treturn null;\n\t}\n\tif (typeof value === \"string\") {\n\t\taddMetadataBytes(stats, jsonStringUtf8Bytes(value), limits);\n\t\treturn value;\n\t}\n\tif (typeof value === \"boolean\") {\n\t\taddMetadataBytes(stats, value ? 4 : 5, limits);\n\t\treturn value;\n\t}\n\tif (typeof value === \"number\") {\n\t\tif (!Number.isFinite(value)) throw new Error(\"metadata numbers must be finite\");\n\t\taddMetadataBytes(stats, Buffer.byteLength(String(value), \"utf8\"), limits);\n\t\treturn value;\n\t}\n\tif (typeof value !== \"object\") throw new Error(\"metadata must contain only JSON-like values\");\n\tif (ancestors.has(value)) throw new Error(\"metadata cannot contain cycles\");\n\n\tancestors.add(value);\n\ttry {\n\t\tif (Array.isArray(value)) {\n\t\t\tif (value.length > limits.maxEntries - stats.entries) {\n\t\t\t\tthrow new Error(`metadata exceeds ${limits.maxEntries} entries`);\n\t\t\t}\n\t\t\tconst ownKeys = Object.keys(value);\n\t\t\tif (ownKeys.length !== value.length)\n\t\t\t\tthrow new Error(\"metadata arrays cannot be sparse or have extra properties\");\n\t\t\tconst copy: unknown[] = [];\n\t\t\taddMetadataBytes(stats, 2, limits);\n\t\t\tfor (let index = 0; index < value.length; index++) {\n\t\t\t\taddMetadataEntry(stats, limits);\n\t\t\t\tif (index > 0) addMetadataBytes(stats, 1, limits);\n\t\t\t\tconst descriptor = Object.getOwnPropertyDescriptor(value, String(index));\n\t\t\t\tif (!descriptor || !(\"value\" in descriptor)) throw new Error(\"metadata cannot contain accessors\");\n\t\t\t\tcopy.push(cloneJsonValue(descriptor.value, depth + 1, ancestors, stats, limits));\n\t\t\t}\n\t\t\treturn copy;\n\t\t}\n\n\t\tif (!isPlainRecord(value)) throw new Error(\"metadata objects must be plain objects\");\n\t\tconst copy: Record<string, unknown> = {};\n\t\tconst keys = Object.keys(value);\n\t\tif (keys.length > limits.maxEntries - stats.entries) {\n\t\t\tthrow new Error(`metadata exceeds ${limits.maxEntries} entries`);\n\t\t}\n\t\taddMetadataBytes(stats, 2, limits);\n\t\tfor (let index = 0; index < keys.length; index++) {\n\t\t\tconst key = keys[index];\n\t\t\tif (limits.maxKeyLength !== undefined && key.length > limits.maxKeyLength) {\n\t\t\t\tthrow new Error(`metadata keys exceed ${limits.maxKeyLength} characters`);\n\t\t\t}\n\t\t\tconst descriptor = Object.getOwnPropertyDescriptor(value, key);\n\t\t\tif (!descriptor || !(\"value\" in descriptor)) throw new Error(\"metadata cannot contain accessors\");\n\t\t\taddMetadataEntry(stats, limits);\n\t\t\tif (index > 0) addMetadataBytes(stats, 1, limits);\n\t\t\taddMetadataBytes(stats, jsonStringUtf8Bytes(key) + 1, limits);\n\t\t\tObject.defineProperty(copy, key, {\n\t\t\t\tvalue: cloneJsonValue(descriptor.value, depth + 1, ancestors, stats, limits),\n\t\t\t\tenumerable: true,\n\t\t\t\twritable: true,\n\t\t\t\tconfigurable: true,\n\t\t\t});\n\t\t}\n\t\treturn copy;\n\t} finally {\n\t\tancestors.delete(value);\n\t}\n}\n\nfunction validateAndCloneMetadata(value: unknown, limits: MetadataLimits): MetadataValidation {\n\tif (!isPlainRecord(value)) return { error: \"metadata must be a plain object\" };\n\ttry {\n\t\tconst cloned = cloneJsonValue(value, 1, new WeakSet<object>(), { entries: 0, bytes: 0 }, limits);\n\t\tif (!isPlainRecord(cloned)) return { error: \"metadata must be a plain object\" };\n\t\treturn { metadata: cloned };\n\t} catch (error) {\n\t\treturn { error: error instanceof Error ? error.message : \"metadata is invalid\" };\n\t}\n}\n\nfunction cloneStoredMetadata(value: Record<string, unknown>): Record<string, unknown> | undefined {\n\tconst result = validateAndCloneMetadata(value, LEGACY_METADATA_LIMITS);\n\treturn \"metadata\" in result ? result.metadata : undefined;\n}\n\nexport function cloneState(source: TodoState): TodoState {\n\treturn {\n\t\titems: source.items.map((item) => {\n\t\t\tconst clone: TodoItem = { ...item };\n\t\t\tif (item.blockedBy) clone.blockedBy = [...item.blockedBy];\n\t\t\telse delete clone.blockedBy;\n\t\t\tif (item.metadata) {\n\t\t\t\tconst metadata = cloneStoredMetadata(item.metadata);\n\t\t\t\tif (metadata) clone.metadata = metadata;\n\t\t\t\telse delete clone.metadata;\n\t\t\t} else delete clone.metadata;\n\t\t\treturn clone;\n\t\t}),\n\t\tnextId: source.nextId,\n\t};\n}\n\nfunction omitBlockedBy(item: TodoItem): TodoItem {\n\tconst next = { ...item };\n\tdelete next.blockedBy;\n\treturn next;\n}\n\n/**\n * Keep a tombstone while detaching its id from every dependent task. Also\n * reports which pending dependents ended up fully unblocked, so the tool\n * result can tell the model what became workable (mirrors the demote note).\n */\nfunction markDeleted(state: TodoState, id: number): { state: TodoState; releasedIds: number[] } {\n\tconst items = state.items.map((item) => {\n\t\tif (item.id === id) return { ...item, status: \"deleted\" as const };\n\t\tif (!item.blockedBy?.includes(id)) return item;\n\t\tconst blockedBy = item.blockedBy.filter((dependencyId) => dependencyId !== id);\n\t\treturn blockedBy.length ? { ...item, blockedBy } : omitBlockedBy(item);\n\t});\n\tconst nextState: TodoState = { items, nextId: state.nextId };\n\tconst releasedIds = state.items\n\t\t.filter((item) => item.id !== id && item.status === \"pending\" && item.blockedBy?.includes(id))\n\t\t.map((item) => item.id)\n\t\t.filter((dependentId) => {\n\t\t\tconst dependent = findItem(nextState, dependentId);\n\t\t\treturn dependent !== undefined && unresolvedDependencyIds(nextState, dependent).length === 0;\n\t\t});\n\treturn { state: nextState, releasedIds };\n}\n\nfunction error(state: TodoState, message: string): MutationResult {\n\treturn { state, operation: { kind: \"error\", message } };\n}\n\n// ---- per-action parameter validation ---------------------------------------\n// Parameters each action accepts. Anything else is rejected with guidance\n// instead of being silently ignored (a model that passes blockedBy on update\n// should learn about addBlockedBy, not lose the edit).\nconst ACTION_PARAMS: Record<TodoAction, ReadonlySet<string>> = {\n\tcreate: new Set([\"subject\", \"description\", \"activeForm\", \"status\", \"blockedBy\", \"owner\", \"metadata\"]),\n\tcreate_many: new Set([\"items\"]),\n\tupdate: new Set([\n\t\t\"id\",\n\t\t\"subject\",\n\t\t\"description\",\n\t\t\"activeForm\",\n\t\t\"status\",\n\t\t\"addBlockedBy\",\n\t\t\"removeBlockedBy\",\n\t\t\"addBlocks\",\n\t\t\"removeBlocks\",\n\t\t\"owner\",\n\t\t\"metadata\",\n\t]),\n\tlist: new Set([\"status\", \"includeDeleted\", \"limit\", \"afterId\", \"query\", \"unblockedOnly\"]),\n\tget: new Set([\"id\"]),\n\tdelete: new Set([\"id\"]),\n\tclear: new Set([\"confirm\", \"expectedCount\"]),\n};\n\nconst UPDATE_ONLY_EDGE_PARAMS = new Set([\"addBlockedBy\", \"removeBlockedBy\", \"addBlocks\", \"removeBlocks\"]);\n\nfunction inapplicableParamError(action: TodoAction, key: string): string {\n\tif (action === \"update\" && key === \"blockedBy\") {\n\t\treturn \"blockedBy is create-only; use addBlockedBy/removeBlockedBy on update\";\n\t}\n\tif (action === \"create\" && UPDATE_ONLY_EDGE_PARAMS.has(key)) {\n\t\treturn `${key} is update-only; use blockedBy on create`;\n\t}\n\treturn `${key} does not apply to action ${action}`;\n}\n\nfunction findInapplicableParam(params: TodoParams): string | undefined {\n\tconst allowed = ACTION_PARAMS[params.action];\n\tfor (const [key, value] of Object.entries(params)) {\n\t\tif (key === \"action\" || value === undefined) continue;\n\t\tif (!allowed.has(key)) return inapplicableParamError(params.action, key);\n\t}\n\treturn undefined;\n}\n\nfunction validateBoundedString(value: unknown, label: string, maximum: number): string | undefined {\n\tif (typeof value !== \"string\") return `${label} must be a string`;\n\tif (value.length > maximum) return `${label} exceeds ${maximum} characters`;\n\treturn undefined;\n}\n\nfunction validateTaskTextFields(params: TodoParams, requireDescription: boolean): string | undefined {\n\tif (params.subject !== undefined) {\n\t\tconst subjectError = validateBoundedString(params.subject, \"subject\", TODO_MAX_SUBJECT_LENGTH);\n\t\tif (subjectError) return subjectError;\n\t}\n\tif (params.description !== undefined) {\n\t\tconst descriptionError = validateBoundedString(params.description, \"description\", TODO_MAX_DESCRIPTION_LENGTH);\n\t\tif (descriptionError) return descriptionError;\n\t}\n\tif (params.activeForm !== undefined) {\n\t\tconst activeFormError = validateBoundedString(params.activeForm, \"activeForm\", TODO_MAX_ACTIVE_FORM_LENGTH);\n\t\tif (activeFormError) return activeFormError;\n\t}\n\tif (params.owner !== undefined) {\n\t\tconst ownerError = validateBoundedString(params.owner, \"owner\", TODO_MAX_OWNER_LENGTH);\n\t\tif (ownerError) return ownerError;\n\t}\n\tif (params.subject !== undefined && !params.subject.trim()) return \"subject cannot be empty\";\n\tif (requireDescription && !params.description?.trim()) {\n\t\treturn \"description required for create: state what done means for this task\";\n\t}\n\treturn undefined;\n}\n\nfunction validateIdArray(value: unknown, label: string): number[] | string {\n\tif (value === undefined) return [];\n\tif (!Array.isArray(value)) return `${label} must be an array`;\n\tif (value.length > TODO_MAX_BLOCKED_BY) return `${label} exceeds ${TODO_MAX_BLOCKED_BY} ids`;\n\tconst ids: number[] = [];\n\tfor (const id of value) {\n\t\tif (!isPositiveInteger(id)) return `${label} must contain positive integer ids`;\n\t\tif (!ids.includes(id)) ids.push(id);\n\t}\n\treturn ids;\n}\n\nfunction isTransitionAllowed(from: TodoStatus, to: TodoStatus): boolean {\n\t// `deleted` is the only terminal status; `completed` can be reopened to\n\t// in_progress or pending so a premature completion recovers without losing\n\t// the task id and its blockedBy edges.\n\tif (from === to) return true;\n\tif (from === \"deleted\") return false;\n\tif (to === \"deleted\") return true;\n\tif (to === \"completed\") return from === \"pending\" || from === \"in_progress\";\n\tif (to === \"pending\") return from === \"in_progress\" || from === \"completed\";\n\tif (to === \"in_progress\") return from === \"pending\" || from === \"completed\";\n\treturn false;\n}\n\nfunction findItem(state: TodoState, id: number): TodoItem | undefined {\n\treturn state.items.find((item) => item.id === id);\n}\n\nfunction validateDependencies(state: TodoState, deps: number[], currentId?: number): string | undefined {\n\tfor (const dep of deps) {\n\t\tif (dep === currentId) return `cannot block #${currentId} on itself`;\n\t\tconst item = findItem(state, dep);\n\t\tif (!item) return `blockedBy: #${dep} not found`;\n\t\tif (item.status === \"deleted\") return `blockedBy: #${dep} is deleted`;\n\t}\n\treturn undefined;\n}\n\n/** All listed dependencies must be completed before starting or closing a task. */\nfunction validateDependenciesReady(state: TodoState, deps: number[]): string | undefined {\n\tfor (const dep of deps) {\n\t\tconst item = findItem(state, dep);\n\t\tif (!item || item.status !== \"completed\") {\n\t\t\treturn `blockedBy: complete #${dep} before starting or finishing this task`;\n\t\t}\n\t}\n\treturn undefined;\n}\n\n/** IDs of dependencies that still prevent a task from starting or completing. */\nexport function unresolvedDependencyIds(state: TodoState, item: TodoItem): number[] {\n\treturn (item.blockedBy ?? []).filter((id) => findItem(state, id)?.status !== \"completed\");\n}\n\n/** True when every blockedBy dependency is completed (for transition and overlay checks). */\nexport function dependenciesSatisfied(state: TodoState, item: TodoItem): boolean {\n\treturn unresolvedDependencyIds(state, item).length === 0;\n}\n\n/** True when the resulting dependency graph contains a cycle. */\nfunction createsCycle(items: TodoItem[], nextDepsById: ReadonlyMap<number, number[]>): boolean {\n\tconst depsById = new Map<number, number[]>();\n\tfor (const item of items) depsById.set(item.id, item.blockedBy ?? []);\n\tfor (const [id, deps] of nextDepsById) depsById.set(id, deps);\n\n\t// An iterative color walk keeps replay and dependency edits safe for long,\n\t// valid chains that would overflow the JavaScript call stack recursively.\n\tconst colors = new Map<number, 0 | 1 | 2>();\n\tconst stack: Array<{ id: number; nextDependencyIndex: number }> = [];\n\tfor (const initialId of depsById.keys()) {\n\t\tif ((colors.get(initialId) ?? 0) !== 0) continue;\n\t\tcolors.set(initialId, 1);\n\t\tstack.push({ id: initialId, nextDependencyIndex: 0 });\n\n\t\twhile (stack.length) {\n\t\t\tconst frame = stack[stack.length - 1]!;\n\t\t\tconst dependencies = depsById.get(frame.id) ?? [];\n\t\t\tif (frame.nextDependencyIndex >= dependencies.length) {\n\t\t\t\tcolors.set(frame.id, 2);\n\t\t\t\tstack.pop();\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst dependencyId = dependencies[frame.nextDependencyIndex]!;\n\t\t\tframe.nextDependencyIndex++;\n\t\t\tif (!depsById.has(dependencyId)) continue;\n\t\t\tconst color = colors.get(dependencyId) ?? 0;\n\t\t\tif (color === 1) return true;\n\t\t\tif (color === 2) continue;\n\t\t\tcolors.set(dependencyId, 1);\n\t\t\tstack.push({ id: dependencyId, nextDependencyIndex: 0 });\n\t\t}\n\t}\n\treturn false;\n}\n\nfunction validateAndNormalizeBatch(params: TodoParams, state: TodoState): BatchItem[] | string {\n\tconst rawItems: unknown = (params as Record<string, unknown>).items;\n\tif (!Array.isArray(rawItems) || rawItems.length === 0) return \"items required for create_many\";\n\tif (rawItems.length > TODO_MAX_BATCH_ITEMS) return `items exceeds ${TODO_MAX_BATCH_ITEMS} tasks`;\n\n\tconst allowedKeys = new Set([\n\t\t\"key\",\n\t\t\"subject\",\n\t\t\"description\",\n\t\t\"activeForm\",\n\t\t\"blockedBy\",\n\t\t\"blockedByKeys\",\n\t\t\"owner\",\n\t\t\"metadata\",\n\t]);\n\tconst batch: BatchItem[] = [];\n\tconst keys = new Map<string, number>();\n\tfor (let index = 0; index < rawItems.length; index++) {\n\t\tconst raw = rawItems[index];\n\t\tif (!isPlainRecord(raw)) return `items[${index}] must be an object`;\n\t\tfor (const property of Object.keys(raw)) {\n\t\t\tif (!allowedKeys.has(property)) return `items[${index}].${property} does not apply to create_many`;\n\t\t}\n\t\tconst taskParams = raw as TodoParams;\n\t\tif (typeof raw.subject !== \"string\" || !raw.subject.trim())\n\t\t\treturn `items[${index}]: subject required for create_many`;\n\t\tconst taskTextError = validateTaskTextFields(taskParams, true);\n\t\tif (taskTextError) return `items[${index}]: ${taskTextError}`;\n\n\t\tlet key: string | undefined;\n\t\tif (raw.key !== undefined) {\n\t\t\tconst keyError = validateBoundedString(raw.key, \"key\", TODO_MAX_BATCH_KEY_LENGTH);\n\t\t\tif (keyError) return `items[${index}]: ${keyError}`;\n\t\t\tkey = (raw.key as string).trim();\n\t\t\tif (!key) return `items[${index}]: key cannot be empty`;\n\t\t\tif (keys.has(key)) return `items[${index}]: duplicate key ${key}`;\n\t\t\tkeys.set(key, index);\n\t\t}\n\n\t\tconst blockedBy = validateIdArray(raw.blockedBy, `items[${index}].blockedBy`);\n\t\tif (typeof blockedBy === \"string\") return blockedBy;\n\t\tconst blockedByError = validateDependencies(state, blockedBy);\n\t\tif (blockedByError) return `items[${index}]: ${blockedByError}`;\n\n\t\tconst rawBlockedByKeys = raw.blockedByKeys;\n\t\tif (rawBlockedByKeys !== undefined && !Array.isArray(rawBlockedByKeys)) {\n\t\t\treturn `items[${index}].blockedByKeys must be an array`;\n\t\t}\n\t\tif (Array.isArray(rawBlockedByKeys) && rawBlockedByKeys.length > TODO_MAX_BLOCKED_BY) {\n\t\t\treturn `items[${index}].blockedByKeys exceeds ${TODO_MAX_BLOCKED_BY} keys`;\n\t\t}\n\t\tconst blockedByKeys: string[] = [];\n\t\tfor (const reference of rawBlockedByKeys ?? []) {\n\t\t\tconst referenceError = validateBoundedString(reference, \"blockedByKeys\", TODO_MAX_BATCH_KEY_LENGTH);\n\t\t\tif (referenceError) return `items[${index}]: ${referenceError}`;\n\t\t\tconst normalizedReference = (reference as string).trim();\n\t\t\tif (!normalizedReference) return `items[${index}]: blockedByKeys cannot contain an empty key`;\n\t\t\tif (!blockedByKeys.includes(normalizedReference)) blockedByKeys.push(normalizedReference);\n\t\t}\n\n\t\tlet metadata: Record<string, unknown> | undefined;\n\t\tif (raw.metadata !== undefined) {\n\t\t\tconst metadataResult = validateAndCloneMetadata(raw.metadata, {\n\t\t\t\tmaxDepth: TODO_MAX_METADATA_DEPTH,\n\t\t\t\tmaxEntries: TODO_MAX_METADATA_ENTRIES,\n\t\t\t\tmaxBytes: TODO_MAX_METADATA_BYTES,\n\t\t\t\tmaxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,\n\t\t\t});\n\t\t\tif (\"error\" in metadataResult) return `items[${index}]: ${metadataResult.error}`;\n\t\t\tmetadata = metadataResult.metadata;\n\t\t}\n\n\t\tbatch.push({\n\t\t\t...(key ? { key } : {}),\n\t\t\tsubject: (raw.subject as string).trim(),\n\t\t\tdescription: raw.description as string,\n\t\t\t...(raw.activeForm ? { activeForm: raw.activeForm as string } : {}),\n\t\t\tblockedBy,\n\t\t\tblockedByKeys,\n\t\t\t...(raw.owner ? { owner: raw.owner as string } : {}),\n\t\t\t...(metadata ? { metadata } : {}),\n\t\t});\n\t}\n\n\tfor (let index = 0; index < batch.length; index++) {\n\t\tfor (const key of batch[index].blockedByKeys) {\n\t\t\tif (!keys.has(key)) return `items[${index}]: blockedByKeys references missing key ${key}`;\n\t\t\tif (key === batch[index].key) return `items[${index}]: cannot block a task on itself`;\n\t\t}\n\t}\n\treturn batch;\n}\n\nfunction createMany(state: TodoState, params: TodoParams): MutationResult {\n\tconst batch = validateAndNormalizeBatch(params, state);\n\tif (typeof batch === \"string\") return error(state, batch);\n\tif (\n\t\t!Number.isSafeInteger(state.nextId) ||\n\t\tstate.nextId < 1 ||\n\t\tstate.nextId + batch.length - 1 > Number.MAX_SAFE_INTEGER\n\t) {\n\t\treturn error(state, \"next id is invalid or exhausted\");\n\t}\n\n\tconst idsByKey = new Map<string, number>();\n\tfor (let index = 0; index < batch.length; index++) {\n\t\tconst key = batch[index].key;\n\t\tif (key) idsByKey.set(key, state.nextId + index);\n\t}\n\tconst created = batch.map((source, index) => {\n\t\tconst blockedBy = [...source.blockedBy];\n\t\tfor (const key of source.blockedByKeys) {\n\t\t\tconst id = idsByKey.get(key);\n\t\t\tif (id !== undefined && !blockedBy.includes(id)) blockedBy.push(id);\n\t\t}\n\t\tconst item: TodoItem = {\n\t\t\tid: state.nextId + index,\n\t\t\tsubject: source.subject,\n\t\t\tdescription: source.description,\n\t\t\tstatus: \"pending\",\n\t\t};\n\t\tif (source.activeForm) item.activeForm = source.activeForm;\n\t\tif (blockedBy.length) item.blockedBy = blockedBy;\n\t\tif (source.owner) item.owner = source.owner;\n\t\tif (source.metadata) item.metadata = source.metadata;\n\t\treturn item;\n\t});\n\tconst batchEdges = new Map<number, number[]>();\n\tfor (const item of created) batchEdges.set(item.id, item.blockedBy ?? []);\n\tif (createsCycle([...state.items, ...created], batchEdges)) return error(state, \"blockedBy would create a cycle\");\n\n\treturn {\n\t\tstate: { items: [...state.items, ...created], nextId: state.nextId + created.length },\n\t\toperation: { kind: \"create_many\", ids: created.map((item) => item.id) },\n\t};\n}\n\nexport function applyTodoMutation(input: TodoState, params: TodoParams): MutationResult {\n\tconst state = cloneState(input);\n\tif (!isTodoAction(params.action)) return error(state, `unknown action: ${String(params.action)}`);\n\n\tconst inapplicable = findInapplicableParam(params);\n\tif (inapplicable) return error(state, inapplicable);\n\n\tswitch (params.action) {\n\t\tcase \"create\": {\n\t\t\tif (typeof params.subject !== \"string\" || !params.subject.trim())\n\t\t\t\treturn error(state, \"subject required for create\");\n\t\t\tconst taskTextError = validateTaskTextFields(params, true);\n\t\t\tif (taskTextError) return error(state, taskTextError);\n\t\t\tif (params.status !== undefined && params.status !== \"pending\") {\n\t\t\t\treturn error(state, \"create always starts pending; use update to start or complete a task\");\n\t\t\t}\n\t\t\tconst blockedBy = validateIdArray(params.blockedBy, \"blockedBy\");\n\t\t\tif (typeof blockedBy === \"string\") return error(state, blockedBy);\n\t\t\tconst dependencyError = validateDependencies(state, blockedBy);\n\t\t\tif (dependencyError) return error(state, dependencyError);\n\n\t\t\tlet metadata: Record<string, unknown> | undefined;\n\t\t\tif (params.metadata !== undefined) {\n\t\t\t\tconst metadataResult = validateAndCloneMetadata(params.metadata, {\n\t\t\t\t\tmaxDepth: TODO_MAX_METADATA_DEPTH,\n\t\t\t\t\tmaxEntries: TODO_MAX_METADATA_ENTRIES,\n\t\t\t\t\tmaxBytes: TODO_MAX_METADATA_BYTES,\n\t\t\t\t\tmaxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,\n\t\t\t\t});\n\t\t\t\tif (\"error\" in metadataResult) return error(state, metadataResult.error);\n\t\t\t\tmetadata = metadataResult.metadata;\n\t\t\t}\n\n\t\t\tif (!Number.isSafeInteger(state.nextId) || state.nextId < 1 || state.nextId === Number.MAX_SAFE_INTEGER) {\n\t\t\t\treturn error(state, \"next id is invalid or exhausted\");\n\t\t\t}\n\t\t\tconst item: TodoItem = {\n\t\t\t\tid: state.nextId,\n\t\t\t\tsubject: (params.subject as string).trim(),\n\t\t\t\tstatus: \"pending\",\n\t\t\t\tdescription: params.description as string,\n\t\t\t};\n\t\t\tif (params.activeForm) item.activeForm = params.activeForm;\n\t\t\tif (blockedBy.length) item.blockedBy = blockedBy;\n\t\t\tif (params.owner) item.owner = params.owner;\n\t\t\tif (metadata) item.metadata = metadata;\n\n\t\t\treturn {\n\t\t\t\tstate: { items: [...state.items, item], nextId: state.nextId + 1 },\n\t\t\t\toperation: { kind: \"create\", id: item.id },\n\t\t\t};\n\t\t}\n\n\t\tcase \"create_many\":\n\t\t\treturn createMany(state, params);\n\n\t\tcase \"update\": {\n\t\t\tif (!isPositiveInteger(params.id)) return error(state, \"id required for update\");\n\t\t\tconst index = state.items.findIndex((item) => item.id === params.id);\n\t\t\tif (index === -1) return error(state, `#${params.id} not found`);\n\t\t\tconst current = state.items[index];\n\t\t\tif (current.status === \"deleted\") return error(state, `#${params.id} is deleted and cannot be updated`);\n\t\t\tconst taskTextError = validateTaskTextFields(params, false);\n\t\t\tif (taskTextError) return error(state, taskTextError);\n\t\t\tif (params.status !== undefined && !isTodoStatus(params.status)) return error(state, \"status is invalid\");\n\n\t\t\tconst addBlockedBy = validateIdArray(params.addBlockedBy, \"addBlockedBy\");\n\t\t\tconst removeBlockedBy = validateIdArray(params.removeBlockedBy, \"removeBlockedBy\");\n\t\t\tconst addBlocks = validateIdArray(params.addBlocks, \"addBlocks\");\n\t\t\tconst removeBlocks = validateIdArray(params.removeBlocks, \"removeBlocks\");\n\t\t\tif (typeof addBlockedBy === \"string\") return error(state, addBlockedBy);\n\t\t\tif (typeof removeBlockedBy === \"string\") return error(state, removeBlockedBy);\n\t\t\tif (typeof addBlocks === \"string\") return error(state, addBlocks);\n\t\t\tif (typeof removeBlocks === \"string\") return error(state, removeBlocks);\n\t\t\tconst edgeChange =\n\t\t\t\taddBlockedBy.length > 0 || removeBlockedBy.length > 0 || addBlocks.length > 0 || removeBlocks.length > 0;\n\t\t\tconst fieldChange =\n\t\t\t\tparams.subject !== undefined ||\n\t\t\t\tparams.description !== undefined ||\n\t\t\t\tparams.activeForm !== undefined ||\n\t\t\t\tparams.owner !== undefined ||\n\t\t\t\tparams.metadata !== undefined;\n\t\t\tif (!fieldChange && !edgeChange && params.status === undefined) {\n\t\t\t\treturn error(state, \"update requires at least one field\");\n\t\t\t}\n\n\t\t\tif (params.status === \"deleted\") {\n\t\t\t\tif (fieldChange || edgeChange) {\n\t\t\t\t\treturn error(\n\t\t\t\t\t\tstate,\n\t\t\t\t\t\t\"status deleted cannot be combined with other changes; apply edits first or delete in a separate call\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tconst deleted = markDeleted(state, current.id);\n\t\t\t\treturn {\n\t\t\t\t\tstate: deleted.state,\n\t\t\t\t\toperation: {\n\t\t\t\t\t\tkind: \"update\",\n\t\t\t\t\t\tid: current.id,\n\t\t\t\t\t\tfrom: current.status,\n\t\t\t\t\t\tto: \"deleted\",\n\t\t\t\t\t\t...(deleted.releasedIds.length ? { releasedIds: deleted.releasedIds } : {}),\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst nextStatus = params.status ?? current.status;\n\t\t\tif (!isTransitionAllowed(current.status, nextStatus)) {\n\t\t\t\treturn error(state, `illegal transition ${current.status} -> ${nextStatus}`);\n\t\t\t}\n\n\t\t\tlet metadata: Record<string, unknown> | undefined;\n\t\t\tif (params.metadata !== undefined) {\n\t\t\t\tconst patchResult = validateAndCloneMetadata(params.metadata, {\n\t\t\t\t\tmaxDepth: TODO_MAX_METADATA_DEPTH,\n\t\t\t\t\tmaxEntries: TODO_MAX_METADATA_ENTRIES,\n\t\t\t\t\tmaxBytes: TODO_MAX_METADATA_BYTES,\n\t\t\t\t\tmaxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,\n\t\t\t\t});\n\t\t\t\tif (\"error\" in patchResult) return error(state, patchResult.error);\n\t\t\t\tconst merged: Record<string, unknown> = Object.create(null) as Record<string, unknown>;\n\t\t\t\tfor (const [key, value] of Object.entries(current.metadata ?? {})) merged[key] = value;\n\t\t\t\tfor (const [key, value] of Object.entries(patchResult.metadata)) {\n\t\t\t\t\tif (value === null) delete merged[key];\n\t\t\t\t\telse merged[key] = value;\n\t\t\t\t}\n\t\t\t\tif (Object.keys(merged).length) {\n\t\t\t\t\tconst mergedResult = validateAndCloneMetadata(merged, {\n\t\t\t\t\t\tmaxDepth: TODO_MAX_METADATA_DEPTH,\n\t\t\t\t\t\tmaxEntries: TODO_MAX_METADATA_ENTRIES,\n\t\t\t\t\t\tmaxBytes: TODO_MAX_METADATA_BYTES,\n\t\t\t\t\t\tmaxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,\n\t\t\t\t\t});\n\t\t\t\t\tif (\"error\" in mergedResult) return error(state, mergedResult.error);\n\t\t\t\t\tmetadata = mergedResult.metadata;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Collect every task whose blockedBy changes: this task (addBlockedBy /\n\t\t\t// removeBlockedBy) plus reverse-edge targets (addBlocks / removeBlocks).\n\t\t\tconst nextDepsById = new Map<number, number[]>();\n\t\t\tlet nextDeps = current.blockedBy ? [...current.blockedBy] : [];\n\t\t\tif (removeBlockedBy.length) {\n\t\t\t\tconst remove = new Set(removeBlockedBy);\n\t\t\t\tnextDeps = nextDeps.filter((id) => !remove.has(id));\n\t\t\t}\n\t\t\tif (addBlockedBy.length) {\n\t\t\t\tconst dependencyError = validateDependencies(state, addBlockedBy, current.id);\n\t\t\t\tif (dependencyError) return error(state, dependencyError);\n\t\t\t\tfor (const dep of addBlockedBy) {\n\t\t\t\t\tif (!nextDeps.includes(dep)) nextDeps.push(dep);\n\t\t\t\t}\n\t\t\t}\n\t\t\tnextDepsById.set(current.id, nextDeps);\n\n\t\t\tif (removeBlocks.length) {\n\t\t\t\tconst release = new Set(removeBlocks);\n\t\t\t\tfor (const candidate of state.items) {\n\t\t\t\t\tif (!release.has(candidate.id) || !candidate.blockedBy?.includes(current.id)) continue;\n\t\t\t\t\tnextDepsById.set(\n\t\t\t\t\t\tcandidate.id,\n\t\t\t\t\t\tcandidate.blockedBy.filter((dependencyId) => dependencyId !== current.id),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (addBlocks.length) {\n\t\t\t\tfor (const targetId of addBlocks) {\n\t\t\t\t\tif (targetId === current.id) return error(state, `cannot block #${targetId} on itself`);\n\t\t\t\t\tconst target = findItem(state, targetId);\n\t\t\t\t\tif (!target) return error(state, `addBlocks: #${targetId} not found`);\n\t\t\t\t\tif (target.status === \"deleted\") return error(state, `addBlocks: #${targetId} is deleted`);\n\t\t\t\t\tconst deps = nextDepsById.get(targetId) ?? (target.blockedBy ? [...target.blockedBy] : []);\n\t\t\t\t\tif (!deps.includes(current.id)) deps.push(current.id);\n\t\t\t\t\tnextDepsById.set(targetId, deps);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (createsCycle(state.items, nextDepsById)) {\n\t\t\t\treturn error(state, \"blockedBy would create a cycle\");\n\t\t\t}\n\n\t\t\t// Readiness gates explicit status changes. An already active task that\n\t\t\t// takes on a new incomplete edge is instead automatically demoted below.\n\t\t\tif ((nextStatus === \"in_progress\" || nextStatus === \"completed\") && current.status !== nextStatus) {\n\t\t\t\tconst readyError = validateDependenciesReady(state, nextDeps);\n\t\t\t\tif (readyError) return error(state, readyError);\n\t\t\t}\n\n\t\t\tconst updated: TodoItem = { ...current, status: nextStatus };\n\t\t\tif (params.subject !== undefined) updated.subject = params.subject.trim();\n\t\t\t// Empty string removes an optional text field (metadata uses null keys).\n\t\t\tif (params.description !== undefined) {\n\t\t\t\tif (params.description) updated.description = params.description;\n\t\t\t\telse delete updated.description;\n\t\t\t}\n\t\t\tif (params.activeForm !== undefined) {\n\t\t\t\tif (params.activeForm) updated.activeForm = params.activeForm;\n\t\t\t\telse delete updated.activeForm;\n\t\t\t}\n\t\t\tif (params.owner !== undefined) {\n\t\t\t\tif (params.owner) updated.owner = params.owner;\n\t\t\t\telse delete updated.owner;\n\t\t\t}\n\t\t\tif (nextDeps.length) updated.blockedBy = nextDeps;\n\t\t\telse delete updated.blockedBy;\n\t\t\tif (params.metadata !== undefined) {\n\t\t\t\tif (metadata) updated.metadata = metadata;\n\t\t\t\telse delete updated.metadata;\n\t\t\t}\n\n\t\t\tconst items = state.items.map((task) => {\n\t\t\t\tif (task.id === current.id) return updated;\n\t\t\t\tconst deps = nextDepsById.get(task.id);\n\t\t\t\tif (deps === undefined) return task;\n\t\t\t\treturn deps.length ? { ...task, blockedBy: deps } : omitBlockedBy(task);\n\t\t\t});\n\n\t\t\t// A task that was already active may become newly blocked through either\n\t\t\t// direction of an edge edit. Keep it out of in_progress and distinguish\n\t\t\t// this from the usual single-active-task demotion in the result.\n\t\t\tconst blockedIds: number[] = [];\n\t\t\tfor (const [targetId, deps] of nextDepsById) {\n\t\t\t\tconst before = findItem(state, targetId);\n\t\t\t\tconst targetIndex = items.findIndex((item) => item.id === targetId);\n\t\t\t\tif (!before || targetIndex === -1 || items[targetIndex].status !== \"in_progress\") continue;\n\t\t\t\tconst previousDeps = before.blockedBy ?? [];\n\t\t\t\tconst gainedUnresolved = deps.some((dependencyId) => {\n\t\t\t\t\tif (previousDeps.includes(dependencyId)) return false;\n\t\t\t\t\tconst dependency = items.find((item) => item.id === dependencyId);\n\t\t\t\t\treturn dependency?.status !== \"completed\";\n\t\t\t\t});\n\t\t\t\tif (gainedUnresolved) {\n\t\t\t\t\titems[targetIndex] = { ...items[targetIndex], status: \"pending\" };\n\t\t\t\t\tblockedIds.push(targetId);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Exactly one in_progress: demote any other active tasks to pending.\n\t\t\tconst demotedIds: number[] = [];\n\t\t\tconst finalCurrent = items[index];\n\t\t\tif (nextStatus === \"in_progress\" && finalCurrent.status === \"in_progress\") {\n\t\t\t\tfor (let itemIndex = 0; itemIndex < items.length; itemIndex++) {\n\t\t\t\t\tconst item = items[itemIndex];\n\t\t\t\t\tif (item.id === finalCurrent.id || item.status !== \"in_progress\") continue;\n\t\t\t\t\titems[itemIndex] = { ...item, status: \"pending\" };\n\t\t\t\t\tdemotedIds.push(item.id);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst nextState: TodoState = { items, nextId: state.nextId };\n\t\t\tconst finalItem = items[index];\n\t\t\tconst newlyCompleted = current.status !== \"completed\" && finalItem.status === \"completed\";\n\t\t\treturn {\n\t\t\t\tstate: nextState,\n\t\t\t\toperation: {\n\t\t\t\t\tkind: \"update\",\n\t\t\t\t\tid: finalItem.id,\n\t\t\t\t\tfrom: current.status,\n\t\t\t\t\tto: finalItem.status,\n\t\t\t\t\t...(demotedIds.length ? { demotedIds } : {}),\n\t\t\t\t\t...(blockedIds.length ? { blockedIds } : {}),\n\t\t\t\t\t...(newlyCompleted && needsVerificationNudge(nextState) ? { verificationNudge: true } : {}),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tcase \"list\": {\n\t\t\tif (params.status !== undefined && !isTodoStatus(params.status)) return error(state, \"status is invalid\");\n\t\t\tif (params.includeDeleted !== undefined && typeof params.includeDeleted !== \"boolean\") {\n\t\t\t\treturn error(state, \"includeDeleted must be a boolean\");\n\t\t\t}\n\t\t\tif (params.unblockedOnly !== undefined && typeof params.unblockedOnly !== \"boolean\") {\n\t\t\t\treturn error(state, \"unblockedOnly must be a boolean\");\n\t\t\t}\n\t\t\tif (\n\t\t\t\tparams.limit !== undefined &&\n\t\t\t\t(!Number.isSafeInteger(params.limit) || params.limit < 1 || params.limit > TODO_MAX_LIST_LIMIT)\n\t\t\t) {\n\t\t\t\treturn error(state, `limit must be an integer from 1 to ${TODO_MAX_LIST_LIMIT}`);\n\t\t\t}\n\t\t\tif (params.afterId !== undefined && !isPositiveInteger(params.afterId))\n\t\t\t\treturn error(state, \"afterId must be a positive integer\");\n\t\t\tif (params.query !== undefined) {\n\t\t\t\tconst queryError = validateBoundedString(params.query, \"query\", TODO_MAX_LIST_QUERY_LENGTH);\n\t\t\t\tif (queryError) return error(state, queryError);\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tstate,\n\t\t\t\toperation: {\n\t\t\t\t\tkind: \"list\",\n\t\t\t\t\tstatus: params.status,\n\t\t\t\t\tincludeDeleted: params.includeDeleted === true,\n\t\t\t\t\tlimit: params.limit ?? LIST_DISPLAY_MAX_ITEMS,\n\t\t\t\t\t...(params.afterId !== undefined ? { afterId: params.afterId } : {}),\n\t\t\t\t\t...(params.query !== undefined ? { query: params.query } : {}),\n\t\t\t\t\tunblockedOnly: params.unblockedOnly === true,\n\t\t\t\t\tlegacyOutput:\n\t\t\t\t\t\tparams.limit === undefined &&\n\t\t\t\t\t\tparams.afterId === undefined &&\n\t\t\t\t\t\tparams.query === undefined &&\n\t\t\t\t\t\tparams.unblockedOnly === undefined,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tcase \"get\": {\n\t\t\tif (!isPositiveInteger(params.id)) return error(state, \"id required for get\");\n\t\t\tconst item = findItem(state, params.id);\n\t\t\tif (!item) return error(state, `#${params.id} not found`);\n\t\t\treturn { state, operation: { kind: \"get\", item } };\n\t\t}\n\n\t\tcase \"delete\": {\n\t\t\tif (!isPositiveInteger(params.id)) return error(state, \"id required for delete\");\n\t\t\tconst index = state.items.findIndex((item) => item.id === params.id);\n\t\t\tif (index === -1) return error(state, `#${params.id} not found`);\n\t\t\tconst current = state.items[index];\n\t\t\tif (current.status === \"deleted\") return error(state, `#${params.id} is already deleted`);\n\n\t\t\tconst deleted = markDeleted(state, current.id);\n\t\t\treturn {\n\t\t\t\tstate: deleted.state,\n\t\t\t\toperation: {\n\t\t\t\t\tkind: \"delete\",\n\t\t\t\t\tid: current.id,\n\t\t\t\t\tsubject: current.subject,\n\t\t\t\t\t...(deleted.releasedIds.length ? { releasedIds: deleted.releasedIds } : {}),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tcase \"clear\":\n\t\t\tif (params.confirm !== true) return error(state, \"clear requires confirm: true\");\n\t\t\tif (!Number.isSafeInteger(params.expectedCount) || params.expectedCount !== state.items.length) {\n\t\t\t\treturn error(state, `clear expectedCount must equal current task count (${state.items.length})`);\n\t\t\t}\n\t\t\treturn {\n\t\t\t\t// IDs remain monotonic across a clear so stale references cannot point\n\t\t\t\t// at an unrelated task created later on the same conversation branch.\n\t\t\t\tstate: { items: [], nextId: state.nextId },\n\t\t\t\toperation: { kind: \"clear\", count: state.items.length },\n\t\t\t};\n\t}\n}\n\nfunction isTodoStatusCounts(value: unknown): boolean {\n\tif (!isPlainRecord(value)) return false;\n\tfor (const [status, count] of Object.entries(value)) {\n\t\tif (!isTodoStatus(status) || typeof count !== \"number\" || !Number.isSafeInteger(count) || count < 0) return false;\n\t}\n\treturn true;\n}\n\nfunction isOperationSummary(value: unknown, action: TodoAction): value is TodoOperationSummary {\n\tif (!isPlainRecord(value) || value.kind !== action) return false;\n\tswitch (action) {\n\t\tcase \"create\":\n\t\tcase \"create_many\":\n\t\t\treturn Array.isArray(value.ids) && value.ids.every(isPositiveInteger);\n\t\tcase \"update\":\n\t\t\treturn isPositiveInteger(value.id) && isTodoStatus(value.status);\n\t\tcase \"list\":\n\t\t\treturn (\n\t\t\t\ttypeof value.includeDeleted === \"boolean\" &&\n\t\t\t\ttypeof value.unblockedOnly === \"boolean\" &&\n\t\t\t\t(value.status === undefined || isTodoStatus(value.status)) &&\n\t\t\t\t(value.limit === undefined ||\n\t\t\t\t\t(typeof value.limit === \"number\" && Number.isSafeInteger(value.limit) && value.limit >= 1)) &&\n\t\t\t\t(value.afterId === undefined || isPositiveInteger(value.afterId)) &&\n\t\t\t\t(value.query === undefined || typeof value.query === \"string\") &&\n\t\t\t\t(value.statusCounts === undefined || isTodoStatusCounts(value.statusCounts)) &&\n\t\t\t\t(value.resultCount === undefined ||\n\t\t\t\t\t(typeof value.resultCount === \"number\" &&\n\t\t\t\t\t\tNumber.isSafeInteger(value.resultCount) &&\n\t\t\t\t\t\tvalue.resultCount >= 0))\n\t\t\t);\n\t\tcase \"get\":\n\t\tcase \"delete\":\n\t\t\treturn isPositiveInteger(value.id);\n\t\tcase \"clear\":\n\t\t\treturn typeof value.count === \"number\" && Number.isSafeInteger(value.count) && value.count >= 0;\n\t}\n}\n\nfunction normalizeSnapshotState(itemsValue: unknown, nextIdValue: unknown): TodoState | undefined {\n\tif (!Array.isArray(itemsValue) || !isPositiveInteger(nextIdValue)) return undefined;\n\tconst items: TodoItem[] = [];\n\tconst ids = new Set<number>();\n\tconst itemsById = new Map<number, TodoItem>();\n\tfor (const rawItem of itemsValue) {\n\t\tif (!isPlainRecord(rawItem) || !isPositiveInteger(rawItem.id) || ids.has(rawItem.id)) return undefined;\n\t\tif (typeof rawItem.subject !== \"string\" || !isTodoStatus(rawItem.status)) return undefined;\n\t\tif (rawItem.description !== undefined && typeof rawItem.description !== \"string\") return undefined;\n\t\tif (rawItem.activeForm !== undefined && typeof rawItem.activeForm !== \"string\") return undefined;\n\t\tif (rawItem.owner !== undefined && typeof rawItem.owner !== \"string\") return undefined;\n\t\tlet blockedBy: number[] | undefined;\n\t\tif (rawItem.blockedBy !== undefined) {\n\t\t\tif (!Array.isArray(rawItem.blockedBy) || !rawItem.blockedBy.every(isPositiveInteger)) return undefined;\n\t\t\tblockedBy = Array.from(new Set(rawItem.blockedBy));\n\t\t}\n\t\tlet metadata: Record<string, unknown> | undefined;\n\t\tif (rawItem.metadata !== undefined) {\n\t\t\tconst metadataResult = validateAndCloneMetadata(rawItem.metadata, LEGACY_METADATA_LIMITS);\n\t\t\tif (\"error\" in metadataResult) return undefined;\n\t\t\tmetadata = metadataResult.metadata;\n\t\t}\n\t\tconst item: TodoItem = { id: rawItem.id, subject: rawItem.subject, status: rawItem.status };\n\t\tif (rawItem.description !== undefined) item.description = rawItem.description;\n\t\tif (rawItem.activeForm !== undefined) item.activeForm = rawItem.activeForm;\n\t\tif (blockedBy?.length) item.blockedBy = blockedBy;\n\t\tif (rawItem.owner !== undefined) item.owner = rawItem.owner;\n\t\tif (metadata) item.metadata = metadata;\n\t\titems.push(item);\n\t\titemsById.set(item.id, item);\n\t\tids.add(item.id);\n\t}\n\tconst highestId = items.reduce((highest, item) => Math.max(highest, item.id), 0);\n\tif (nextIdValue <= highestId) return undefined;\n\tfor (const item of items) {\n\t\tfor (const dependencyId of item.blockedBy ?? []) {\n\t\t\tconst dependency = itemsById.get(dependencyId);\n\t\t\tif (!dependency || dependency.status === \"deleted\") return undefined;\n\t\t}\n\t}\n\tif (createsCycle(items, new Map<number, number[]>())) return undefined;\n\n\t// Legacy snapshots could contain the formerly permitted \"active but newly\n\t// blocked\" state. Normalize it to the current invariant rather than losing\n\t// the newest otherwise-valid task list during replay.\n\tfor (const item of items) {\n\t\tif (item.status !== \"in_progress\") continue;\n\t\tif ((item.blockedBy ?? []).some((id) => itemsById.get(id)?.status !== \"completed\")) {\n\t\t\titem.status = \"pending\";\n\t\t}\n\t}\n\tif (items.filter((item) => item.status === \"in_progress\").length > 1) return undefined;\n\titems.sort((first, second) => first.id - second.id);\n\treturn { items, nextId: nextIdValue };\n}\n\nfunction normalizeDetails(value: unknown): TodoState | undefined {\n\tif (!isPlainRecord(value) || !isTodoAction(value.action) || !isPlainRecord(value.params)) return undefined;\n\tif (value.schemaVersion !== undefined) {\n\t\tif (value.schemaVersion !== TODO_DETAILS_SCHEMA_VERSION || !isOperationSummary(value.operation, value.action)) {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\treturn normalizeSnapshotState(value.items, value.nextId);\n}\n\nexport function replayTodosFromBranch(ctx: { sessionManager: { getBranch(): Iterable<unknown> } }): TodoState {\n\t// Latest valid todo toolResult wins; scan tail → head so malformed latest\n\t// snapshots cannot poison a branch that still has an earlier valid state.\n\tconst branch = Array.from(ctx.sessionManager.getBranch());\n\tfor (let index = branch.length - 1; index >= 0; index--) {\n\t\tconst entry = branch[index] as {\n\t\t\ttype?: string;\n\t\t\tmessage?: { role?: string; toolName?: string; details?: unknown };\n\t\t};\n\t\tif (entry.type !== \"message\") continue;\n\t\tif (entry.message?.role !== \"toolResult\" || entry.message.toolName !== TODO_TOOL_NAME) continue;\n\t\tlet state: TodoState | undefined;\n\t\ttry {\n\t\t\tstate = normalizeDetails(entry.message.details);\n\t\t} catch {\n\t\t\t// Session history is external input; ignore hostile or malformed details.\n\t\t\tcontinue;\n\t\t}\n\t\tif (state) return cloneState(state);\n\t}\n\treturn cloneState(EMPTY_TODO_STATE);\n}\n\nfunction cloneDetailsParams(params: TodoParams): Record<string, unknown> {\n\tconst copy: Record<string, unknown> = {};\n\tfor (const [key, value] of Object.entries(params)) {\n\t\tif (value === undefined) continue;\n\t\tif (key === \"metadata\") {\n\t\t\tconst metadata = validateAndCloneMetadata(value, LEGACY_METADATA_LIMITS);\n\t\t\tif (\"metadata\" in metadata) copy[key] = metadata.metadata;\n\t\t\tcontinue;\n\t\t}\n\t\tif (key === \"items\" && Array.isArray(value)) {\n\t\t\tcopy[key] = value.map((item) => {\n\t\t\t\tif (!isPlainRecord(item)) return item;\n\t\t\t\tconst itemCopy: Record<string, unknown> = { ...item };\n\t\t\t\tif (item.metadata !== undefined) {\n\t\t\t\t\tconst metadata = validateAndCloneMetadata(item.metadata, LEGACY_METADATA_LIMITS);\n\t\t\t\t\tif (\"metadata\" in metadata) itemCopy.metadata = metadata.metadata;\n\t\t\t\t}\n\t\t\t\treturn itemCopy;\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tcopy[key] = value;\n\t}\n\treturn copy;\n}\n\nfunction buildTodoOperationSummary(params: TodoParams, next: TodoState): TodoOperationSummary {\n\tswitch (params.action) {\n\t\tcase \"create\":\n\t\t\treturn { kind: \"create\", ids: [next.nextId - 1] };\n\t\tcase \"create_many\": {\n\t\t\tconst count = Array.isArray(params.items) ? params.items.length : 0;\n\t\t\tconst firstId = next.nextId - count;\n\t\t\treturn { kind: \"create_many\", ids: Array.from({ length: count }, (_, index) => firstId + index) };\n\t\t}\n\t\tcase \"update\": {\n\t\t\tconst item = findItem(next, params.id as number);\n\t\t\treturn { kind: \"update\", id: params.id as number, status: item?.status ?? \"pending\" };\n\t\t}\n\t\tcase \"list\": {\n\t\t\tconst listOperation: Extract<Operation, { kind: \"list\" }> = {\n\t\t\t\tkind: \"list\",\n\t\t\t\t...(params.status !== undefined ? { status: params.status } : {}),\n\t\t\t\tincludeDeleted: params.includeDeleted === true,\n\t\t\t\tlimit: params.limit ?? LIST_DISPLAY_MAX_ITEMS,\n\t\t\t\t...(params.afterId !== undefined ? { afterId: params.afterId } : {}),\n\t\t\t\t...(params.query !== undefined ? { query: params.query } : {}),\n\t\t\t\tunblockedOnly: params.unblockedOnly === true,\n\t\t\t\tlegacyOutput: false,\n\t\t\t};\n\t\t\tconst shown = getListView(listOperation, next).slice(0, listOperation.limit);\n\t\t\tconst statusCounts: Partial<Record<TodoStatus, number>> = {};\n\t\t\tfor (const item of shown) statusCounts[item.status] = (statusCounts[item.status] ?? 0) + 1;\n\t\t\treturn {\n\t\t\t\tkind: \"list\",\n\t\t\t\t...(listOperation.status !== undefined ? { status: listOperation.status } : {}),\n\t\t\t\tincludeDeleted: listOperation.includeDeleted,\n\t\t\t\tlimit: listOperation.limit,\n\t\t\t\t...(listOperation.afterId !== undefined ? { afterId: listOperation.afterId } : {}),\n\t\t\t\t...(listOperation.query !== undefined ? { query: listOperation.query } : {}),\n\t\t\t\tunblockedOnly: listOperation.unblockedOnly,\n\t\t\t\tstatusCounts,\n\t\t\t\tresultCount: shown.length,\n\t\t\t};\n\t\t}\n\t\tcase \"get\":\n\t\t\treturn { kind: \"get\", id: params.id as number };\n\t\tcase \"delete\":\n\t\t\treturn { kind: \"delete\", id: params.id as number };\n\t\tcase \"clear\":\n\t\t\treturn { kind: \"clear\", count: params.expectedCount as number };\n\t}\n}\n\nexport function buildTodoDetails(params: TodoParams, next: TodoState): TodoDetails {\n\tconst snapshot = cloneState(next);\n\treturn {\n\t\tschemaVersion: TODO_DETAILS_SCHEMA_VERSION,\n\t\taction: params.action,\n\t\tparams: cloneDetailsParams(params),\n\t\toperation: buildTodoOperationSummary(params, snapshot),\n\t\titems: snapshot.items,\n\t\tnextId: snapshot.nextId,\n\t};\n}\n\nconst VERIFICATION_NUDGE_NOTE =\n\t\"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.\";\n\nfunction formatReleasedSuffix(releasedIds: number[] | undefined): string {\n\tif (!releasedIds?.length) return \"\";\n\treturn `; released ${releasedIds.map((id) => `#${id}`).join(\",\")} (no longer blocked)`;\n}\n\nfunction formatDisplayText(value: string, maximum: number): string {\n\treturn value.length > maximum ? `${value.slice(0, maximum)}…` : value;\n}\n\nfunction getListView(operation: Extract<Operation, { kind: \"list\" }>, state: TodoState): TodoItem[] {\n\tlet view = state.items;\n\t// An explicit deleted-status query is sufficient intent to include tombstones.\n\tif (!operation.includeDeleted && operation.status !== \"deleted\") {\n\t\tview = view.filter((item) => item.status !== \"deleted\");\n\t}\n\tif (operation.status) view = view.filter((item) => item.status === operation.status);\n\tif (operation.afterId !== undefined) {\n\t\tconst afterId = operation.afterId;\n\t\tview = view.filter((item) => item.id > afterId);\n\t}\n\tif (operation.query !== undefined) {\n\t\tconst query = operation.query.toLocaleLowerCase();\n\t\tview = view.filter(\n\t\t\t(item) =>\n\t\t\t\titem.subject.toLocaleLowerCase().includes(query) || item.description?.toLocaleLowerCase().includes(query),\n\t\t);\n\t}\n\tif (operation.unblockedOnly) view = view.filter((item) => dependenciesSatisfied(state, item));\n\treturn [...view].sort((first, second) => first.id - second.id);\n}\n\nexport function formatTodoContent(operation: Operation, state: TodoState): string {\n\tswitch (operation.kind) {\n\t\tcase \"create\": {\n\t\t\tconst item = findItem(state, operation.id);\n\t\t\treturn item\n\t\t\t\t? `Created #${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (pending)`\n\t\t\t\t: `Created #${operation.id}`;\n\t\t}\n\t\tcase \"create_many\": {\n\t\t\tconst noun = operation.ids.length === 1 ? \"task\" : \"tasks\";\n\t\t\tconst lines = operation.ids.map((id) => {\n\t\t\t\tconst item = findItem(state, id);\n\t\t\t\treturn item\n\t\t\t\t\t? `#${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (${item.status})`\n\t\t\t\t\t: `#${id}`;\n\t\t\t});\n\t\t\treturn `Created ${operation.ids.length} ${noun}:${lines.length ? `\\n${lines.join(\"\\n\")}` : \"\"}`;\n\t\t}\n\t\tcase \"update\": {\n\t\t\tconst transition = operation.from === operation.to ? \"\" : ` (${operation.from} -> ${operation.to})`;\n\t\t\tlet text = `Updated #${operation.id}${transition}`;\n\t\t\tif (operation.demotedIds?.length) {\n\t\t\t\ttext += `; ${operation.demotedIds.map((id) => `#${id}`).join(\",\")} demoted to pending`;\n\t\t\t}\n\t\t\tif (operation.blockedIds?.length) {\n\t\t\t\ttext += `; ${operation.blockedIds.map((id) => `#${id}`).join(\",\")} moved to pending (dependencies incomplete)`;\n\t\t\t}\n\t\t\ttext += formatReleasedSuffix(operation.releasedIds);\n\t\t\tif (operation.verificationNudge) text += `\\n\\n${VERIFICATION_NUDGE_NOTE}`;\n\t\t\treturn text;\n\t\t}\n\t\tcase \"delete\":\n\t\t\treturn `Deleted #${operation.id}: ${formatDisplayText(operation.subject, TODO_MAX_SUBJECT_LENGTH)}${formatReleasedSuffix(operation.releasedIds)}`;\n\t\tcase \"clear\":\n\t\t\treturn `Cleared ${operation.count} tasks`;\n\t\tcase \"list\":\n\t\t\treturn formatBoundedList(getListView(operation, state), state, operation);\n\t\tcase \"get\":\n\t\t\treturn formatDetailItem(operation.item, state);\n\t\tcase \"error\":\n\t\t\treturn `Error: ${operation.message}`;\n\t}\n}\n\n/**\n * Soft nudge when the whole list is done (no active work), there are 3+ completed\n * tasks, and none look like a verification/test/review step — mirrors CC TodoWrite.\n */\nfunction needsVerificationNudge(state: TodoState): boolean {\n\tconst active = state.items.filter((item) => item.status !== \"deleted\");\n\tif (active.length < 3) return false;\n\tif (active.some((item) => item.status === \"pending\" || item.status === \"in_progress\")) return false;\n\tconst completed = active.filter((item) => item.status === \"completed\");\n\tif (completed.length < 3) return false;\n\treturn !completed.some(\n\t\t(item) =>\n\t\t\tVERIFICATION_PATTERN.test(item.subject) ||\n\t\t\t(item.description !== undefined && VERIFICATION_PATTERN.test(item.description)),\n\t);\n}\n\nfunction formatBoundedList(\n\tview: TodoItem[],\n\tstate: TodoState,\n\toperation: Extract<Operation, { kind: \"list\" }>,\n): string {\n\tif (!view.length) return \"No tasks\";\n\tconst shown = view.slice(0, operation.limit);\n\tconst lines = shown.map((item) => formatListItem(item, state));\n\tconst omitted = view.length - shown.length;\n\tif (omitted > 0) {\n\t\tif (!operation.legacyOutput) {\n\t\t\tlines.push(`… and ${omitted} more; next page: afterId=${shown.at(-1)?.id}.`);\n\t\t} else {\n\t\t\t// Do not suggest a filter that is already applied.\n\t\t\tconst hint = operation.status ? \"use get with id= for details\" : \"narrow with status= or use get with id=\";\n\t\t\tlines.push(`… and ${omitted} more (${shown.length} of ${view.length} shown); ${hint}.`);\n\t\t}\n\t}\n\treturn lines.join(\"\\n\");\n}\n\nfunction formatListIdList(ids: number[]): string {\n\tconst shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);\n\treturn ids.length > shown.length ? `${shown.join(\",\")},…+${ids.length - shown.length}` : shown.join(\",\");\n}\n\nfunction formatListItem(item: TodoItem, state: TodoState): string {\n\tconst unresolved = unresolvedDependencyIds(state, item);\n\tconst deps = unresolved.length ? ` blockedBy=${formatListIdList(unresolved)}` : \"\";\n\tconst owner = item.owner ? ` @${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}` : \"\";\n\tconst active =\n\t\titem.status === \"in_progress\" && item.activeForm\n\t\t\t? ` (${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)})`\n\t\t\t: \"\";\n\treturn `[${item.status}] #${item.id} ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}${active}${owner}${deps}`;\n}\n\nfunction formatIdList(ids: number[]): string {\n\tconst shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);\n\treturn ids.length > shown.length ? `${shown.join(\", \")}, … +${ids.length - shown.length}` : shown.join(\", \");\n}\n\nfunction formatDetailItem(item: TodoItem, state: TodoState): string {\n\tconst lines = [`#${item.id} [${item.status}] ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}`];\n\tif (item.description) lines.push(`description: ${formatDisplayText(item.description, TODO_MAX_DESCRIPTION_LENGTH)}`);\n\tif (item.activeForm) lines.push(`activeForm: ${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)}`);\n\tif (item.blockedBy?.length) lines.push(`blockedBy: ${formatIdList(item.blockedBy)}`);\n\tconst blocks = state.items\n\t\t.filter((candidate) => candidate.blockedBy?.includes(item.id))\n\t\t.map((candidate) => candidate.id);\n\tif (blocks.length) lines.push(`blocks: ${formatIdList(blocks)}`);\n\tif (item.owner) lines.push(`owner: ${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}`);\n\treturn lines.join(\"\\n\");\n}\n"]}
|
|
@@ -1235,8 +1235,16 @@ export function formatTodoContent(operation, state) {
|
|
|
1235
1235
|
? `Created #${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (pending)`
|
|
1236
1236
|
: `Created #${operation.id}`;
|
|
1237
1237
|
}
|
|
1238
|
-
case "create_many":
|
|
1239
|
-
|
|
1238
|
+
case "create_many": {
|
|
1239
|
+
const noun = operation.ids.length === 1 ? "task" : "tasks";
|
|
1240
|
+
const lines = operation.ids.map((id) => {
|
|
1241
|
+
const item = findItem(state, id);
|
|
1242
|
+
return item
|
|
1243
|
+
? `#${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (${item.status})`
|
|
1244
|
+
: `#${id}`;
|
|
1245
|
+
});
|
|
1246
|
+
return `Created ${operation.ids.length} ${noun}:${lines.length ? `\n${lines.join("\n")}` : ""}`;
|
|
1247
|
+
}
|
|
1240
1248
|
case "update": {
|
|
1241
1249
|
const transition = operation.from === operation.to ? "" : ` (${operation.from} -> ${operation.to})`;
|
|
1242
1250
|
let text = `Updated #${operation.id}${transition}`;
|