@astralyn/pi 0.82.5 → 0.82.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/cli/project-trust.d.ts.map +1 -1
- package/dist/cli/project-trust.js +4 -1
- package/dist/cli/project-trust.js.map +1 -1
- package/dist/core/bash-executor.d.ts.map +1 -1
- package/dist/core/bash-executor.js +4 -22
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/extensions/index.d.ts +1 -1
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/types.d.ts +28 -5
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/tools/output-accumulator.d.ts +3 -13
- package/dist/core/tools/output-accumulator.d.ts.map +1 -1
- package/dist/core/tools/output-accumulator.js +6 -36
- package/dist/core/tools/output-accumulator.js.map +1 -1
- package/dist/extensions/plan/constants.d.ts +11 -2
- package/dist/extensions/plan/constants.d.ts.map +1 -1
- package/dist/extensions/plan/constants.js +14 -2
- package/dist/extensions/plan/constants.js.map +1 -1
- package/dist/extensions/plan/index.d.ts +0 -18
- package/dist/extensions/plan/index.d.ts.map +1 -1
- package/dist/extensions/plan/index.js +35 -3
- package/dist/extensions/plan/index.js.map +1 -1
- package/dist/extensions/plan/view.d.ts +36 -0
- package/dist/extensions/plan/view.d.ts.map +1 -0
- package/dist/extensions/plan/view.js +74 -0
- package/dist/extensions/plan/view.js.map +1 -0
- package/dist/extensions/router/stream.d.ts.map +1 -1
- package/dist/extensions/router/stream.js +44 -0
- package/dist/extensions/router/stream.js.map +1 -1
- package/dist/extensions/subagent/activity.d.ts.map +1 -1
- package/dist/extensions/subagent/activity.js +25 -4
- package/dist/extensions/subagent/activity.js.map +1 -1
- package/dist/extensions/subagent/index.d.ts.map +1 -1
- package/dist/extensions/subagent/index.js +14 -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 +243 -116
- package/dist/extensions/subagent/render.js.map +1 -1
- package/dist/extensions/subagent/resolve.d.ts.map +1 -1
- package/dist/extensions/subagent/resolve.js +13 -9
- package/dist/extensions/subagent/resolve.js.map +1 -1
- package/dist/extensions/subagent/runner.d.ts.map +1 -1
- package/dist/extensions/subagent/runner.js +4 -2
- package/dist/extensions/subagent/runner.js.map +1 -1
- package/dist/extensions/subagent/schema.d.ts.map +1 -1
- package/dist/extensions/subagent/schema.js +2 -2
- package/dist/extensions/subagent/schema.js.map +1 -1
- package/dist/extensions/todo/constants.d.ts +17 -6
- package/dist/extensions/todo/constants.d.ts.map +1 -1
- package/dist/extensions/todo/constants.js +22 -9
- package/dist/extensions/todo/constants.js.map +1 -1
- package/dist/extensions/todo/index.d.ts +0 -14
- package/dist/extensions/todo/index.d.ts.map +1 -1
- package/dist/extensions/todo/index.js +13 -1
- package/dist/extensions/todo/index.js.map +1 -1
- package/dist/extensions/todo/schema.d.ts +53 -2
- package/dist/extensions/todo/schema.d.ts.map +1 -1
- package/dist/extensions/todo/schema.js +57 -8
- package/dist/extensions/todo/schema.js.map +1 -1
- package/dist/extensions/todo/state.d.ts +16 -5
- package/dist/extensions/todo/state.d.ts.map +1 -1
- package/dist/extensions/todo/state.js +866 -130
- package/dist/extensions/todo/state.js.map +1 -1
- package/dist/extensions/todo/view.d.ts +16 -1
- package/dist/extensions/todo/view.d.ts.map +1 -1
- package/dist/extensions/todo/view.js +283 -1
- package/dist/extensions/todo/view.js.map +1 -1
- package/dist/modes/interactive/components/extension-selector.d.ts +6 -1
- package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/extension-selector.js +14 -6
- package/dist/modes/interactive/components/extension-selector.js.map +1 -1
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +6 -0
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +7 -1
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +8 -1
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/docs/bundled/extensions/plan.md +6 -4
- package/docs/bundled/extensions/subagent.md +2 -2
- package/docs/bundled/extensions/todo.md +8 -6
- package/docs/bundled/tool-presentation.md +1 -1
- package/docs/extensions.md +14 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/utils/output-decoder.d.ts +0 -52
- package/dist/utils/output-decoder.d.ts.map +0 -1
- package/dist/utils/output-decoder.js +0 -134
- package/dist/utils/output-decoder.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Static, Type } from "typebox";
|
|
2
2
|
export type TodoStatus = "pending" | "in_progress" | "completed" | "deleted";
|
|
3
|
-
export type TodoAction = "create" | "update" | "list" | "get" | "delete" | "clear";
|
|
3
|
+
export type TodoAction = "create" | "create_many" | "update" | "list" | "get" | "delete" | "clear";
|
|
4
4
|
export interface TodoItem {
|
|
5
5
|
id: number;
|
|
6
6
|
subject: string;
|
|
@@ -15,19 +15,64 @@ export interface TodoState {
|
|
|
15
15
|
items: TodoItem[];
|
|
16
16
|
nextId: number;
|
|
17
17
|
}
|
|
18
|
+
/** Current shape of snapshots written to todo tool results. */
|
|
19
|
+
export declare const TODO_DETAILS_SCHEMA_VERSION = 1;
|
|
20
|
+
export type TodoOperationSummary = {
|
|
21
|
+
kind: "create";
|
|
22
|
+
ids: number[];
|
|
23
|
+
} | {
|
|
24
|
+
kind: "create_many";
|
|
25
|
+
ids: number[];
|
|
26
|
+
} | {
|
|
27
|
+
kind: "update";
|
|
28
|
+
id: number;
|
|
29
|
+
status: TodoStatus;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "list";
|
|
32
|
+
status?: TodoStatus;
|
|
33
|
+
includeDeleted: boolean;
|
|
34
|
+
limit?: number;
|
|
35
|
+
afterId?: number;
|
|
36
|
+
query?: string;
|
|
37
|
+
unblockedOnly: boolean;
|
|
38
|
+
/** Exact status counts for the returned page, not the full snapshot. */
|
|
39
|
+
statusCounts?: Partial<Record<TodoStatus, number>>;
|
|
40
|
+
resultCount?: number;
|
|
41
|
+
} | {
|
|
42
|
+
kind: "get";
|
|
43
|
+
id: number;
|
|
44
|
+
} | {
|
|
45
|
+
kind: "delete";
|
|
46
|
+
id: number;
|
|
47
|
+
} | {
|
|
48
|
+
kind: "clear";
|
|
49
|
+
count: number;
|
|
50
|
+
};
|
|
18
51
|
export interface TodoDetails {
|
|
52
|
+
schemaVersion: typeof TODO_DETAILS_SCHEMA_VERSION;
|
|
19
53
|
action: TodoAction;
|
|
20
54
|
params: Record<string, unknown>;
|
|
55
|
+
operation: TodoOperationSummary;
|
|
21
56
|
items: TodoItem[];
|
|
22
57
|
nextId: number;
|
|
23
58
|
}
|
|
24
59
|
export declare const TodoParamsSchema: Type.TObject<{
|
|
25
|
-
action: Type.TUnsafe<"clear" | "create" | "delete" | "get" | "list" | "update">;
|
|
60
|
+
action: Type.TUnsafe<"clear" | "create" | "create_many" | "delete" | "get" | "list" | "update">;
|
|
26
61
|
subject: Type.TOptional<Type.TString>;
|
|
27
62
|
description: Type.TOptional<Type.TString>;
|
|
28
63
|
activeForm: Type.TOptional<Type.TString>;
|
|
29
64
|
status: Type.TOptional<Type.TUnsafe<"completed" | "deleted" | "in_progress" | "pending">>;
|
|
30
65
|
blockedBy: Type.TOptional<Type.TArray<Type.TInteger>>;
|
|
66
|
+
items: Type.TOptional<Type.TArray<Type.TObject<{
|
|
67
|
+
key: Type.TOptional<Type.TString>;
|
|
68
|
+
subject: Type.TString;
|
|
69
|
+
description: Type.TString;
|
|
70
|
+
activeForm: Type.TOptional<Type.TString>;
|
|
71
|
+
blockedBy: Type.TOptional<Type.TArray<Type.TInteger>>;
|
|
72
|
+
blockedByKeys: Type.TOptional<Type.TArray<Type.TString>>;
|
|
73
|
+
owner: Type.TOptional<Type.TString>;
|
|
74
|
+
metadata: Type.TOptional<Type.TRecord<"^.*$", Type.TUnknown>>;
|
|
75
|
+
}>>>;
|
|
31
76
|
addBlockedBy: Type.TOptional<Type.TArray<Type.TInteger>>;
|
|
32
77
|
removeBlockedBy: Type.TOptional<Type.TArray<Type.TInteger>>;
|
|
33
78
|
addBlocks: Type.TOptional<Type.TArray<Type.TInteger>>;
|
|
@@ -36,6 +81,12 @@ export declare const TodoParamsSchema: Type.TObject<{
|
|
|
36
81
|
metadata: Type.TOptional<Type.TRecord<"^.*$", Type.TUnknown>>;
|
|
37
82
|
id: Type.TOptional<Type.TInteger>;
|
|
38
83
|
includeDeleted: Type.TOptional<Type.TBoolean>;
|
|
84
|
+
limit: Type.TOptional<Type.TInteger>;
|
|
85
|
+
afterId: Type.TOptional<Type.TInteger>;
|
|
86
|
+
query: Type.TOptional<Type.TString>;
|
|
87
|
+
unblockedOnly: Type.TOptional<Type.TBoolean>;
|
|
88
|
+
confirm: Type.TOptional<Type.TBoolean>;
|
|
89
|
+
expectedCount: Type.TOptional<Type.TInteger>;
|
|
39
90
|
}>;
|
|
40
91
|
export type TodoParams = Static<typeof TodoParamsSchema>;
|
|
41
92
|
export declare const EMPTY_TODO_STATE: TodoState;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../src/extensions/todo/schema.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,aAAa,GAAG,WAAW,GAAG,SAAS,CAAC;AAC7E,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEnF,MAAM,WAAW,QAAQ;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,UAAU,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,SAAS;IACzB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC3B,MAAM,EAAE,UAAU,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CACf;AAcD,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;EAgE3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEzD,eAAO,MAAM,gBAAgB,EAAE,SAAoC,CAAC","sourcesContent":["import { StringEnum } from \"@earendil-works/pi-ai\";\nimport { type Static, Type } from \"typebox\";\n\nexport type TodoStatus = \"pending\" | \"in_progress\" | \"completed\" | \"deleted\";\nexport type TodoAction = \"create\" | \"update\" | \"list\" | \"get\" | \"delete\" | \"clear\";\n\nexport interface TodoItem {\n\tid: number;\n\tsubject: string;\n\tdescription?: string;\n\tactiveForm?: string;\n\tstatus: TodoStatus;\n\tblockedBy?: number[];\n\towner?: string;\n\tmetadata?: Record<string, unknown>;\n}\n\nexport interface TodoState {\n\titems: TodoItem[];\n\tnextId: number;\n}\n\nexport interface TodoDetails {\n\taction: TodoAction;\n\tparams: Record<string, unknown>;\n\titems: TodoItem[];\n\tnextId: number;\n}\n\nconst StatusSchema = StringEnum([\"pending\", \"in_progress\", \"completed\", \"deleted\"] as const, {\n\tdescription:\n\t\t\"Task status: pending for future work, in_progress for the single active task, completed for verified done work, and deleted for an immutable obsolete-task tombstone. Set deleted through update or use the delete action. With action list, status also acts as a filter: only tasks with this status are listed.\",\n});\n\nconst ActionSchema = StringEnum([\"create\", \"update\", \"list\", \"get\", \"delete\", \"clear\"] as const, {\n\tdescription:\n\t\t\"Todo operation: create a pending task, update status/details/dependencies, list current tasks (optionally filtered by status), get one task, delete an obsolete task and release dependents, or clear the list without reusing ids. Parameters that do not apply to the chosen action are rejected.\",\n});\n\nconst TaskId = (description: string) => Type.Integer({ minimum: 1, description });\n\nexport const TodoParamsSchema = Type.Object({\n\taction: ActionSchema,\n\tsubject: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Short imperative task subject, required for create; use a reviewable unit of work, not a micro-step.\",\n\t\t}),\n\t),\n\tdescription: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"What done means for this task: notes, acceptance criteria, or verification detail. Required for create; on update an empty string removes it.\",\n\t\t}),\n\t),\n\tactiveForm: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Present-continuous label shown while in_progress, such as 'reading code' or 'updating prompts'; on update an empty string removes it.\",\n\t\t}),\n\t),\n\tstatus: Type.Optional(StatusSchema),\n\tblockedBy: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id this task depends on.\"), {\n\t\t\tdescription:\n\t\t\t\t\"Initial dependency ids, create only (update rejects it; use addBlockedBy/removeBlockedBy there); use only for real ordering constraints.\",\n\t\t}),\n\t),\n\taddBlockedBy: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id that must complete first.\"), {\n\t\t\tdescription: \"Dependency ids to add on update; dependencies must exist and cannot create cycles.\",\n\t\t}),\n\t),\n\tremoveBlockedBy: Type.Optional(\n\t\tType.Array(TaskId(\"Dependency id to detach.\"), { description: \"Dependency ids to remove on update.\" }),\n\t),\n\taddBlocks: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id that must wait for this one.\"), {\n\t\t\tdescription:\n\t\t\t\t\"Update only: ids of tasks that cannot start until this task completes (the reverse of addBlockedBy); targets must exist and cannot create cycles.\",\n\t\t}),\n\t),\n\tremoveBlocks: Type.Optional(\n\t\tType.Array(TaskId(\"Dependent task id to release.\"), {\n\t\t\tdescription: \"Update only: ids of tasks that should no longer wait for this task.\",\n\t\t}),\n\t),\n\towner: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Optional owner or agent label for multi-agent coordination; on update an empty string removes it.\",\n\t\t}),\n\t),\n\tmetadata: Type.Optional(\n\t\tType.Record(Type.String(), Type.Unknown(), {\n\t\t\tdescription: \"Optional structured metadata for integrations; null values delete keys on update.\",\n\t\t}),\n\t),\n\tid: Type.Optional(TaskId(\"Task id, required for update, get, and delete.\")),\n\tincludeDeleted: Type.Optional(\n\t\tType.Boolean({\n\t\t\tdescription:\n\t\t\t\t\"Include deleted tombstones in unfiltered list output; an explicit status=deleted query includes them automatically.\",\n\t\t}),\n\t),\n});\n\nexport type TodoParams = Static<typeof TodoParamsSchema>;\n\nexport const EMPTY_TODO_STATE: TodoState = { items: [], nextId: 1 };\n"]}
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../src/extensions/todo/schema.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAe5C,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,aAAa,GAAG,WAAW,GAAG,SAAS,CAAC;AAC7E,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,aAAa,GAAG,QAAQ,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEnG,MAAM,WAAW,QAAQ;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,UAAU,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,SAAS;IACzB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CACf;AAED,+DAA+D;AAC/D,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAE7C,MAAM,MAAM,oBAAoB,GAC7B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,EAAE,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,GAAG,EAAE,MAAM,EAAE,CAAA;CAAE,GACtC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,UAAU,CAAA;CAAE,GAClD;IACA,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;IACvB,wEAAwE;IACxE,YAAY,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,WAAW,CAAC,EAAE,MAAM,CAAC;CACpB,GACD;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAC3B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpC,MAAM,WAAW,WAAW;IAC3B,aAAa,EAAE,OAAO,2BAA2B,CAAC;IAClD,MAAM,EAAE,UAAU,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,SAAS,EAAE,oBAAoB,CAAC;IAChC,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CACf;AAgDD,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAyF3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEzD,eAAO,MAAM,gBAAgB,EAAE,SAAoC,CAAC","sourcesContent":["import { StringEnum } from \"@earendil-works/pi-ai\";\nimport { type Static, Type } from \"typebox\";\nimport {\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_ENTRIES,\n\tTODO_MAX_METADATA_KEY_LENGTH,\n\tTODO_MAX_OWNER_LENGTH,\n\tTODO_MAX_SUBJECT_LENGTH,\n} from \"./constants.ts\";\n\nexport type TodoStatus = \"pending\" | \"in_progress\" | \"completed\" | \"deleted\";\nexport type TodoAction = \"create\" | \"create_many\" | \"update\" | \"list\" | \"get\" | \"delete\" | \"clear\";\n\nexport interface TodoItem {\n\tid: number;\n\tsubject: string;\n\tdescription?: string;\n\tactiveForm?: string;\n\tstatus: TodoStatus;\n\tblockedBy?: number[];\n\towner?: string;\n\tmetadata?: Record<string, unknown>;\n}\n\nexport interface TodoState {\n\titems: TodoItem[];\n\tnextId: number;\n}\n\n/** Current shape of snapshots written to todo tool results. */\nexport const TODO_DETAILS_SCHEMA_VERSION = 1;\n\nexport type TodoOperationSummary =\n\t| { kind: \"create\"; ids: number[] }\n\t| { kind: \"create_many\"; ids: number[] }\n\t| { kind: \"update\"; id: number; status: TodoStatus }\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/** Exact status counts for the returned page, not the full snapshot. */\n\t\t\tstatusCounts?: Partial<Record<TodoStatus, number>>;\n\t\t\tresultCount?: number;\n\t }\n\t| { kind: \"get\"; id: number }\n\t| { kind: \"delete\"; id: number }\n\t| { kind: \"clear\"; count: number };\n\nexport interface TodoDetails {\n\tschemaVersion: typeof TODO_DETAILS_SCHEMA_VERSION;\n\taction: TodoAction;\n\tparams: Record<string, unknown>;\n\toperation: TodoOperationSummary;\n\titems: TodoItem[];\n\tnextId: number;\n}\n\nconst StatusSchema = StringEnum([\"pending\", \"in_progress\", \"completed\", \"deleted\"] as const, {\n\tdescription:\n\t\t\"Task status: pending for future work, in_progress for the single active task, completed for verified done work, and deleted for an immutable obsolete-task tombstone. Set deleted through update or use the delete action. With action list, status also acts as a filter: only tasks with this status are listed.\",\n});\n\nconst ActionSchema = StringEnum([\"create\", \"create_many\", \"update\", \"list\", \"get\", \"delete\", \"clear\"] as const, {\n\tdescription:\n\t\t\"Todo operation: create one pending task, create_many pending tasks atomically, update status/details/dependencies, list current tasks, get one task, delete an obsolete task, or clear the list with confirmation. Parameters that do not apply to the chosen action are rejected.\",\n});\n\nconst TaskId = (description: string) => Type.Integer({ minimum: 1, description });\n\nconst MetadataSchema = Type.Record(Type.String({ maxLength: TODO_MAX_METADATA_KEY_LENGTH }), Type.Unknown(), {\n\tmaxProperties: TODO_MAX_METADATA_ENTRIES,\n\tdescription: \"Structured JSON-like metadata. Values and total payload are validated by the tool.\",\n});\n\nconst CreateManyItemSchema = Type.Object({\n\tkey: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: TODO_MAX_BATCH_KEY_LENGTH,\n\t\t\tdescription: \"Optional unique batch key used by blockedByKeys references.\",\n\t\t}),\n\t),\n\tsubject: Type.String({\n\t\tmaxLength: TODO_MAX_SUBJECT_LENGTH,\n\t\tdescription: \"Short imperative task subject.\",\n\t}),\n\tdescription: Type.String({\n\t\tmaxLength: TODO_MAX_DESCRIPTION_LENGTH,\n\t\tdescription: \"What done means for this task.\",\n\t}),\n\tactiveForm: Type.Optional(Type.String({ maxLength: TODO_MAX_ACTIVE_FORM_LENGTH })),\n\tblockedBy: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id this task depends on.\"), { maxItems: TODO_MAX_BLOCKED_BY }),\n\t),\n\tblockedByKeys: Type.Optional(\n\t\tType.Array(Type.String({ maxLength: TODO_MAX_BATCH_KEY_LENGTH }), {\n\t\t\tmaxItems: TODO_MAX_BLOCKED_BY,\n\t\t\tdescription: \"Keys of tasks in this create_many batch that must complete first.\",\n\t\t}),\n\t),\n\towner: Type.Optional(Type.String({ maxLength: TODO_MAX_OWNER_LENGTH })),\n\tmetadata: Type.Optional(MetadataSchema),\n});\n\nexport const TodoParamsSchema = Type.Object({\n\taction: ActionSchema,\n\tsubject: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: TODO_MAX_SUBJECT_LENGTH,\n\t\t\tdescription:\n\t\t\t\t\"Short imperative task subject, required for create; use a reviewable unit of work, not a micro-step.\",\n\t\t}),\n\t),\n\tdescription: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: TODO_MAX_DESCRIPTION_LENGTH,\n\t\t\tdescription:\n\t\t\t\t\"What done means for this task: notes, acceptance criteria, or verification detail. Required for create; on update an empty string removes it.\",\n\t\t}),\n\t),\n\tactiveForm: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: TODO_MAX_ACTIVE_FORM_LENGTH,\n\t\t\tdescription:\n\t\t\t\t\"Present-continuous label shown while in_progress, such as 'reading code' or 'updating prompts'; on update an empty string removes it.\",\n\t\t}),\n\t),\n\tstatus: Type.Optional(StatusSchema),\n\tblockedBy: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id this task depends on.\"), {\n\t\t\tmaxItems: TODO_MAX_BLOCKED_BY,\n\t\t\tdescription:\n\t\t\t\t\"Initial dependency ids, create only (update rejects it; use addBlockedBy/removeBlockedBy there); use only for real ordering constraints.\",\n\t\t}),\n\t),\n\titems: Type.Optional(\n\t\tType.Array(CreateManyItemSchema, {\n\t\t\tminItems: 1,\n\t\t\tmaxItems: TODO_MAX_BATCH_ITEMS,\n\t\t\tdescription:\n\t\t\t\t\"create_many only: up to 20 pending tasks, created atomically in input order. Use key and blockedByKeys for dependencies within this batch.\",\n\t\t}),\n\t),\n\taddBlockedBy: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id that must complete first.\"), {\n\t\t\tmaxItems: TODO_MAX_BLOCKED_BY,\n\t\t\tdescription: \"Dependency ids to add on update; dependencies must exist and cannot create cycles.\",\n\t\t}),\n\t),\n\tremoveBlockedBy: Type.Optional(Type.Array(TaskId(\"Dependency id to detach.\"), { maxItems: TODO_MAX_BLOCKED_BY })),\n\taddBlocks: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id that must wait for this one.\"), {\n\t\t\tmaxItems: TODO_MAX_BLOCKED_BY,\n\t\t\tdescription:\n\t\t\t\t\"Update only: ids of tasks that cannot start until this task completes (the reverse of addBlockedBy); targets must exist and cannot create cycles.\",\n\t\t}),\n\t),\n\tremoveBlocks: Type.Optional(Type.Array(TaskId(\"Dependent task id to release.\"), { maxItems: TODO_MAX_BLOCKED_BY })),\n\towner: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: TODO_MAX_OWNER_LENGTH,\n\t\t\tdescription:\n\t\t\t\t\"Optional owner or agent label for multi-agent coordination; on update an empty string removes it.\",\n\t\t}),\n\t),\n\tmetadata: Type.Optional(\n\t\tType.Record(Type.String({ maxLength: TODO_MAX_METADATA_KEY_LENGTH }), Type.Unknown(), {\n\t\t\tmaxProperties: TODO_MAX_METADATA_ENTRIES,\n\t\t\tdescription: \"Optional bounded JSON-like structured metadata; null values delete keys on update.\",\n\t\t}),\n\t),\n\tid: Type.Optional(TaskId(\"Task id, required for update, get, and delete.\")),\n\tincludeDeleted: Type.Optional(\n\t\tType.Boolean({\n\t\t\tdescription:\n\t\t\t\t\"Include deleted tombstones in unfiltered list output; an explicit status=deleted query includes them automatically.\",\n\t\t}),\n\t),\n\tlimit: Type.Optional(\n\t\tType.Integer({ minimum: 1, maximum: TODO_MAX_LIST_LIMIT, description: \"List page size (1 through 50).\" }),\n\t),\n\tafterId: Type.Optional(TaskId(\"List only tasks with ids greater than this id.\")),\n\tquery: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: TODO_MAX_LIST_QUERY_LENGTH,\n\t\t\tdescription: \"Case-insensitive subject and description search for list.\",\n\t\t}),\n\t),\n\tunblockedOnly: Type.Optional(Type.Boolean({ description: \"List only tasks with no unresolved dependencies.\" })),\n\tconfirm: Type.Optional(Type.Boolean({ description: \"clear only: must be true.\" })),\n\texpectedCount: Type.Optional(\n\t\tType.Integer({ minimum: 0, description: \"clear only: exact current task count required as a safety check.\" }),\n\t),\n});\n\nexport type TodoParams = Static<typeof TodoParamsSchema>;\n\nexport const EMPTY_TODO_STATE: TodoState = { items: [], nextId: 1 };\n"]}
|
|
@@ -1,47 +1,96 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
+
import { TODO_MAX_ACTIVE_FORM_LENGTH, TODO_MAX_BATCH_ITEMS, TODO_MAX_BATCH_KEY_LENGTH, TODO_MAX_BLOCKED_BY, TODO_MAX_DESCRIPTION_LENGTH, TODO_MAX_LIST_LIMIT, TODO_MAX_LIST_QUERY_LENGTH, TODO_MAX_METADATA_ENTRIES, TODO_MAX_METADATA_KEY_LENGTH, TODO_MAX_OWNER_LENGTH, TODO_MAX_SUBJECT_LENGTH, } from "./constants.js";
|
|
4
|
+
/** Current shape of snapshots written to todo tool results. */
|
|
5
|
+
export const TODO_DETAILS_SCHEMA_VERSION = 1;
|
|
3
6
|
const StatusSchema = StringEnum(["pending", "in_progress", "completed", "deleted"], {
|
|
4
7
|
description: "Task status: pending for future work, in_progress for the single active task, completed for verified done work, and deleted for an immutable obsolete-task tombstone. Set deleted through update or use the delete action. With action list, status also acts as a filter: only tasks with this status are listed.",
|
|
5
8
|
});
|
|
6
|
-
const ActionSchema = StringEnum(["create", "update", "list", "get", "delete", "clear"], {
|
|
7
|
-
description: "Todo operation: create
|
|
9
|
+
const ActionSchema = StringEnum(["create", "create_many", "update", "list", "get", "delete", "clear"], {
|
|
10
|
+
description: "Todo operation: create one pending task, create_many pending tasks atomically, update status/details/dependencies, list current tasks, get one task, delete an obsolete task, or clear the list with confirmation. Parameters that do not apply to the chosen action are rejected.",
|
|
8
11
|
});
|
|
9
12
|
const TaskId = (description) => Type.Integer({ minimum: 1, description });
|
|
13
|
+
const MetadataSchema = Type.Record(Type.String({ maxLength: TODO_MAX_METADATA_KEY_LENGTH }), Type.Unknown(), {
|
|
14
|
+
maxProperties: TODO_MAX_METADATA_ENTRIES,
|
|
15
|
+
description: "Structured JSON-like metadata. Values and total payload are validated by the tool.",
|
|
16
|
+
});
|
|
17
|
+
const CreateManyItemSchema = Type.Object({
|
|
18
|
+
key: Type.Optional(Type.String({
|
|
19
|
+
maxLength: TODO_MAX_BATCH_KEY_LENGTH,
|
|
20
|
+
description: "Optional unique batch key used by blockedByKeys references.",
|
|
21
|
+
})),
|
|
22
|
+
subject: Type.String({
|
|
23
|
+
maxLength: TODO_MAX_SUBJECT_LENGTH,
|
|
24
|
+
description: "Short imperative task subject.",
|
|
25
|
+
}),
|
|
26
|
+
description: Type.String({
|
|
27
|
+
maxLength: TODO_MAX_DESCRIPTION_LENGTH,
|
|
28
|
+
description: "What done means for this task.",
|
|
29
|
+
}),
|
|
30
|
+
activeForm: Type.Optional(Type.String({ maxLength: TODO_MAX_ACTIVE_FORM_LENGTH })),
|
|
31
|
+
blockedBy: Type.Optional(Type.Array(TaskId("Existing task id this task depends on."), { maxItems: TODO_MAX_BLOCKED_BY })),
|
|
32
|
+
blockedByKeys: Type.Optional(Type.Array(Type.String({ maxLength: TODO_MAX_BATCH_KEY_LENGTH }), {
|
|
33
|
+
maxItems: TODO_MAX_BLOCKED_BY,
|
|
34
|
+
description: "Keys of tasks in this create_many batch that must complete first.",
|
|
35
|
+
})),
|
|
36
|
+
owner: Type.Optional(Type.String({ maxLength: TODO_MAX_OWNER_LENGTH })),
|
|
37
|
+
metadata: Type.Optional(MetadataSchema),
|
|
38
|
+
});
|
|
10
39
|
export const TodoParamsSchema = Type.Object({
|
|
11
40
|
action: ActionSchema,
|
|
12
41
|
subject: Type.Optional(Type.String({
|
|
42
|
+
maxLength: TODO_MAX_SUBJECT_LENGTH,
|
|
13
43
|
description: "Short imperative task subject, required for create; use a reviewable unit of work, not a micro-step.",
|
|
14
44
|
})),
|
|
15
45
|
description: Type.Optional(Type.String({
|
|
46
|
+
maxLength: TODO_MAX_DESCRIPTION_LENGTH,
|
|
16
47
|
description: "What done means for this task: notes, acceptance criteria, or verification detail. Required for create; on update an empty string removes it.",
|
|
17
48
|
})),
|
|
18
49
|
activeForm: Type.Optional(Type.String({
|
|
50
|
+
maxLength: TODO_MAX_ACTIVE_FORM_LENGTH,
|
|
19
51
|
description: "Present-continuous label shown while in_progress, such as 'reading code' or 'updating prompts'; on update an empty string removes it.",
|
|
20
52
|
})),
|
|
21
53
|
status: Type.Optional(StatusSchema),
|
|
22
54
|
blockedBy: Type.Optional(Type.Array(TaskId("Existing task id this task depends on."), {
|
|
55
|
+
maxItems: TODO_MAX_BLOCKED_BY,
|
|
23
56
|
description: "Initial dependency ids, create only (update rejects it; use addBlockedBy/removeBlockedBy there); use only for real ordering constraints.",
|
|
24
57
|
})),
|
|
58
|
+
items: Type.Optional(Type.Array(CreateManyItemSchema, {
|
|
59
|
+
minItems: 1,
|
|
60
|
+
maxItems: TODO_MAX_BATCH_ITEMS,
|
|
61
|
+
description: "create_many only: up to 20 pending tasks, created atomically in input order. Use key and blockedByKeys for dependencies within this batch.",
|
|
62
|
+
})),
|
|
25
63
|
addBlockedBy: Type.Optional(Type.Array(TaskId("Existing task id that must complete first."), {
|
|
64
|
+
maxItems: TODO_MAX_BLOCKED_BY,
|
|
26
65
|
description: "Dependency ids to add on update; dependencies must exist and cannot create cycles.",
|
|
27
66
|
})),
|
|
28
|
-
removeBlockedBy: Type.Optional(Type.Array(TaskId("Dependency id to detach."), {
|
|
67
|
+
removeBlockedBy: Type.Optional(Type.Array(TaskId("Dependency id to detach."), { maxItems: TODO_MAX_BLOCKED_BY })),
|
|
29
68
|
addBlocks: Type.Optional(Type.Array(TaskId("Existing task id that must wait for this one."), {
|
|
69
|
+
maxItems: TODO_MAX_BLOCKED_BY,
|
|
30
70
|
description: "Update only: ids of tasks that cannot start until this task completes (the reverse of addBlockedBy); targets must exist and cannot create cycles.",
|
|
31
71
|
})),
|
|
32
|
-
removeBlocks: Type.Optional(Type.Array(TaskId("Dependent task id to release."), {
|
|
33
|
-
description: "Update only: ids of tasks that should no longer wait for this task.",
|
|
34
|
-
})),
|
|
72
|
+
removeBlocks: Type.Optional(Type.Array(TaskId("Dependent task id to release."), { maxItems: TODO_MAX_BLOCKED_BY })),
|
|
35
73
|
owner: Type.Optional(Type.String({
|
|
74
|
+
maxLength: TODO_MAX_OWNER_LENGTH,
|
|
36
75
|
description: "Optional owner or agent label for multi-agent coordination; on update an empty string removes it.",
|
|
37
76
|
})),
|
|
38
|
-
metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
|
|
39
|
-
|
|
77
|
+
metadata: Type.Optional(Type.Record(Type.String({ maxLength: TODO_MAX_METADATA_KEY_LENGTH }), Type.Unknown(), {
|
|
78
|
+
maxProperties: TODO_MAX_METADATA_ENTRIES,
|
|
79
|
+
description: "Optional bounded JSON-like structured metadata; null values delete keys on update.",
|
|
40
80
|
})),
|
|
41
81
|
id: Type.Optional(TaskId("Task id, required for update, get, and delete.")),
|
|
42
82
|
includeDeleted: Type.Optional(Type.Boolean({
|
|
43
83
|
description: "Include deleted tombstones in unfiltered list output; an explicit status=deleted query includes them automatically.",
|
|
44
84
|
})),
|
|
85
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: TODO_MAX_LIST_LIMIT, description: "List page size (1 through 50)." })),
|
|
86
|
+
afterId: Type.Optional(TaskId("List only tasks with ids greater than this id.")),
|
|
87
|
+
query: Type.Optional(Type.String({
|
|
88
|
+
maxLength: TODO_MAX_LIST_QUERY_LENGTH,
|
|
89
|
+
description: "Case-insensitive subject and description search for list.",
|
|
90
|
+
})),
|
|
91
|
+
unblockedOnly: Type.Optional(Type.Boolean({ description: "List only tasks with no unresolved dependencies." })),
|
|
92
|
+
confirm: Type.Optional(Type.Boolean({ description: "clear only: must be true." })),
|
|
93
|
+
expectedCount: Type.Optional(Type.Integer({ minimum: 0, description: "clear only: exact current task count required as a safety check." })),
|
|
45
94
|
});
|
|
46
95
|
export const EMPTY_TODO_STATE = { items: [], nextId: 1 };
|
|
47
96
|
//# sourceMappingURL=schema.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../src/extensions/todo/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACnD,OAAO,EAAe,IAAI,EAAE,MAAM,SAAS,CAAC;AA4B5C,MAAM,YAAY,GAAG,UAAU,CAAC,CAAC,SAAS,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,CAAU,EAAE;IAC5F,WAAW,EACV,oTAAoT;CACrT,CAAC,CAAC;AAEH,MAAM,YAAY,GAAG,UAAU,CAAC,CAAC,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAU,EAAE;IAChG,WAAW,EACV,qSAAqS;CACtS,CAAC,CAAC;AAEH,MAAM,MAAM,GAAG,CAAC,WAAmB,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC;AAElF,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC,MAAM,CAAC;IAC3C,MAAM,EAAE,YAAY;IACpB,OAAO,EAAE,IAAI,CAAC,QAAQ,CACrB,IAAI,CAAC,MAAM,CAAC;QACX,WAAW,EACV,sGAAsG;KACvG,CAAC,CACF;IACD,WAAW,EAAE,IAAI,CAAC,QAAQ,CACzB,IAAI,CAAC,MAAM,CAAC;QACX,WAAW,EACV,+IAA+I;KAChJ,CAAC,CACF;IACD,UAAU,EAAE,IAAI,CAAC,QAAQ,CACxB,IAAI,CAAC,MAAM,CAAC;QACX,WAAW,EACV,uIAAuI;KACxI,CAAC,CACF;IACD,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC;IACnC,SAAS,EAAE,IAAI,CAAC,QAAQ,CACvB,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,wCAAwC,CAAC,EAAE;QAC5D,WAAW,EACV,0IAA0I;KAC3I,CAAC,CACF;IACD,YAAY,EAAE,IAAI,CAAC,QAAQ,CAC1B,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,4CAA4C,CAAC,EAAE;QAChE,WAAW,EAAE,oFAAoF;KACjG,CAAC,CACF;IACD,eAAe,EAAE,IAAI,CAAC,QAAQ,CAC7B,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,0BAA0B,CAAC,EAAE,EAAE,WAAW,EAAE,qCAAqC,EAAE,CAAC,CACtG;IACD,SAAS,EAAE,IAAI,CAAC,QAAQ,CACvB,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,+CAA+C,CAAC,EAAE;QACnE,WAAW,EACV,mJAAmJ;KACpJ,CAAC,CACF;IACD,YAAY,EAAE,IAAI,CAAC,QAAQ,CAC1B,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,+BAA+B,CAAC,EAAE;QACnD,WAAW,EAAE,qEAAqE;KAClF,CAAC,CACF;IACD,KAAK,EAAE,IAAI,CAAC,QAAQ,CACnB,IAAI,CAAC,MAAM,CAAC;QACX,WAAW,EACV,mGAAmG;KACpG,CAAC,CACF;IACD,QAAQ,EAAE,IAAI,CAAC,QAAQ,CACtB,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,IAAI,CAAC,OAAO,EAAE,EAAE;QAC1C,WAAW,EAAE,mFAAmF;KAChG,CAAC,CACF;IACD,EAAE,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,gDAAgD,CAAC,CAAC;IAC3E,cAAc,EAAE,IAAI,CAAC,QAAQ,CAC5B,IAAI,CAAC,OAAO,CAAC;QACZ,WAAW,EACV,qHAAqH;KACtH,CAAC,CACF;CACD,CAAC,CAAC;AAIH,MAAM,CAAC,MAAM,gBAAgB,GAAc,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC","sourcesContent":["import { StringEnum } from \"@earendil-works/pi-ai\";\nimport { type Static, Type } from \"typebox\";\n\nexport type TodoStatus = \"pending\" | \"in_progress\" | \"completed\" | \"deleted\";\nexport type TodoAction = \"create\" | \"update\" | \"list\" | \"get\" | \"delete\" | \"clear\";\n\nexport interface TodoItem {\n\tid: number;\n\tsubject: string;\n\tdescription?: string;\n\tactiveForm?: string;\n\tstatus: TodoStatus;\n\tblockedBy?: number[];\n\towner?: string;\n\tmetadata?: Record<string, unknown>;\n}\n\nexport interface TodoState {\n\titems: TodoItem[];\n\tnextId: number;\n}\n\nexport interface TodoDetails {\n\taction: TodoAction;\n\tparams: Record<string, unknown>;\n\titems: TodoItem[];\n\tnextId: number;\n}\n\nconst StatusSchema = StringEnum([\"pending\", \"in_progress\", \"completed\", \"deleted\"] as const, {\n\tdescription:\n\t\t\"Task status: pending for future work, in_progress for the single active task, completed for verified done work, and deleted for an immutable obsolete-task tombstone. Set deleted through update or use the delete action. With action list, status also acts as a filter: only tasks with this status are listed.\",\n});\n\nconst ActionSchema = StringEnum([\"create\", \"update\", \"list\", \"get\", \"delete\", \"clear\"] as const, {\n\tdescription:\n\t\t\"Todo operation: create a pending task, update status/details/dependencies, list current tasks (optionally filtered by status), get one task, delete an obsolete task and release dependents, or clear the list without reusing ids. Parameters that do not apply to the chosen action are rejected.\",\n});\n\nconst TaskId = (description: string) => Type.Integer({ minimum: 1, description });\n\nexport const TodoParamsSchema = Type.Object({\n\taction: ActionSchema,\n\tsubject: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Short imperative task subject, required for create; use a reviewable unit of work, not a micro-step.\",\n\t\t}),\n\t),\n\tdescription: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"What done means for this task: notes, acceptance criteria, or verification detail. Required for create; on update an empty string removes it.\",\n\t\t}),\n\t),\n\tactiveForm: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Present-continuous label shown while in_progress, such as 'reading code' or 'updating prompts'; on update an empty string removes it.\",\n\t\t}),\n\t),\n\tstatus: Type.Optional(StatusSchema),\n\tblockedBy: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id this task depends on.\"), {\n\t\t\tdescription:\n\t\t\t\t\"Initial dependency ids, create only (update rejects it; use addBlockedBy/removeBlockedBy there); use only for real ordering constraints.\",\n\t\t}),\n\t),\n\taddBlockedBy: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id that must complete first.\"), {\n\t\t\tdescription: \"Dependency ids to add on update; dependencies must exist and cannot create cycles.\",\n\t\t}),\n\t),\n\tremoveBlockedBy: Type.Optional(\n\t\tType.Array(TaskId(\"Dependency id to detach.\"), { description: \"Dependency ids to remove on update.\" }),\n\t),\n\taddBlocks: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id that must wait for this one.\"), {\n\t\t\tdescription:\n\t\t\t\t\"Update only: ids of tasks that cannot start until this task completes (the reverse of addBlockedBy); targets must exist and cannot create cycles.\",\n\t\t}),\n\t),\n\tremoveBlocks: Type.Optional(\n\t\tType.Array(TaskId(\"Dependent task id to release.\"), {\n\t\t\tdescription: \"Update only: ids of tasks that should no longer wait for this task.\",\n\t\t}),\n\t),\n\towner: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Optional owner or agent label for multi-agent coordination; on update an empty string removes it.\",\n\t\t}),\n\t),\n\tmetadata: Type.Optional(\n\t\tType.Record(Type.String(), Type.Unknown(), {\n\t\t\tdescription: \"Optional structured metadata for integrations; null values delete keys on update.\",\n\t\t}),\n\t),\n\tid: Type.Optional(TaskId(\"Task id, required for update, get, and delete.\")),\n\tincludeDeleted: Type.Optional(\n\t\tType.Boolean({\n\t\t\tdescription:\n\t\t\t\t\"Include deleted tombstones in unfiltered list output; an explicit status=deleted query includes them automatically.\",\n\t\t}),\n\t),\n});\n\nexport type TodoParams = Static<typeof TodoParamsSchema>;\n\nexport const EMPTY_TODO_STATE: TodoState = { items: [], nextId: 1 };\n"]}
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../src/extensions/todo/schema.ts"],"names":[],"mappings":"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","sourcesContent":["import { StringEnum } from \"@earendil-works/pi-ai\";\nimport { type Static, Type } from \"typebox\";\nimport {\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_ENTRIES,\n\tTODO_MAX_METADATA_KEY_LENGTH,\n\tTODO_MAX_OWNER_LENGTH,\n\tTODO_MAX_SUBJECT_LENGTH,\n} from \"./constants.ts\";\n\nexport type TodoStatus = \"pending\" | \"in_progress\" | \"completed\" | \"deleted\";\nexport type TodoAction = \"create\" | \"create_many\" | \"update\" | \"list\" | \"get\" | \"delete\" | \"clear\";\n\nexport interface TodoItem {\n\tid: number;\n\tsubject: string;\n\tdescription?: string;\n\tactiveForm?: string;\n\tstatus: TodoStatus;\n\tblockedBy?: number[];\n\towner?: string;\n\tmetadata?: Record<string, unknown>;\n}\n\nexport interface TodoState {\n\titems: TodoItem[];\n\tnextId: number;\n}\n\n/** Current shape of snapshots written to todo tool results. */\nexport const TODO_DETAILS_SCHEMA_VERSION = 1;\n\nexport type TodoOperationSummary =\n\t| { kind: \"create\"; ids: number[] }\n\t| { kind: \"create_many\"; ids: number[] }\n\t| { kind: \"update\"; id: number; status: TodoStatus }\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/** Exact status counts for the returned page, not the full snapshot. */\n\t\t\tstatusCounts?: Partial<Record<TodoStatus, number>>;\n\t\t\tresultCount?: number;\n\t }\n\t| { kind: \"get\"; id: number }\n\t| { kind: \"delete\"; id: number }\n\t| { kind: \"clear\"; count: number };\n\nexport interface TodoDetails {\n\tschemaVersion: typeof TODO_DETAILS_SCHEMA_VERSION;\n\taction: TodoAction;\n\tparams: Record<string, unknown>;\n\toperation: TodoOperationSummary;\n\titems: TodoItem[];\n\tnextId: number;\n}\n\nconst StatusSchema = StringEnum([\"pending\", \"in_progress\", \"completed\", \"deleted\"] as const, {\n\tdescription:\n\t\t\"Task status: pending for future work, in_progress for the single active task, completed for verified done work, and deleted for an immutable obsolete-task tombstone. Set deleted through update or use the delete action. With action list, status also acts as a filter: only tasks with this status are listed.\",\n});\n\nconst ActionSchema = StringEnum([\"create\", \"create_many\", \"update\", \"list\", \"get\", \"delete\", \"clear\"] as const, {\n\tdescription:\n\t\t\"Todo operation: create one pending task, create_many pending tasks atomically, update status/details/dependencies, list current tasks, get one task, delete an obsolete task, or clear the list with confirmation. Parameters that do not apply to the chosen action are rejected.\",\n});\n\nconst TaskId = (description: string) => Type.Integer({ minimum: 1, description });\n\nconst MetadataSchema = Type.Record(Type.String({ maxLength: TODO_MAX_METADATA_KEY_LENGTH }), Type.Unknown(), {\n\tmaxProperties: TODO_MAX_METADATA_ENTRIES,\n\tdescription: \"Structured JSON-like metadata. Values and total payload are validated by the tool.\",\n});\n\nconst CreateManyItemSchema = Type.Object({\n\tkey: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: TODO_MAX_BATCH_KEY_LENGTH,\n\t\t\tdescription: \"Optional unique batch key used by blockedByKeys references.\",\n\t\t}),\n\t),\n\tsubject: Type.String({\n\t\tmaxLength: TODO_MAX_SUBJECT_LENGTH,\n\t\tdescription: \"Short imperative task subject.\",\n\t}),\n\tdescription: Type.String({\n\t\tmaxLength: TODO_MAX_DESCRIPTION_LENGTH,\n\t\tdescription: \"What done means for this task.\",\n\t}),\n\tactiveForm: Type.Optional(Type.String({ maxLength: TODO_MAX_ACTIVE_FORM_LENGTH })),\n\tblockedBy: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id this task depends on.\"), { maxItems: TODO_MAX_BLOCKED_BY }),\n\t),\n\tblockedByKeys: Type.Optional(\n\t\tType.Array(Type.String({ maxLength: TODO_MAX_BATCH_KEY_LENGTH }), {\n\t\t\tmaxItems: TODO_MAX_BLOCKED_BY,\n\t\t\tdescription: \"Keys of tasks in this create_many batch that must complete first.\",\n\t\t}),\n\t),\n\towner: Type.Optional(Type.String({ maxLength: TODO_MAX_OWNER_LENGTH })),\n\tmetadata: Type.Optional(MetadataSchema),\n});\n\nexport const TodoParamsSchema = Type.Object({\n\taction: ActionSchema,\n\tsubject: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: TODO_MAX_SUBJECT_LENGTH,\n\t\t\tdescription:\n\t\t\t\t\"Short imperative task subject, required for create; use a reviewable unit of work, not a micro-step.\",\n\t\t}),\n\t),\n\tdescription: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: TODO_MAX_DESCRIPTION_LENGTH,\n\t\t\tdescription:\n\t\t\t\t\"What done means for this task: notes, acceptance criteria, or verification detail. Required for create; on update an empty string removes it.\",\n\t\t}),\n\t),\n\tactiveForm: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: TODO_MAX_ACTIVE_FORM_LENGTH,\n\t\t\tdescription:\n\t\t\t\t\"Present-continuous label shown while in_progress, such as 'reading code' or 'updating prompts'; on update an empty string removes it.\",\n\t\t}),\n\t),\n\tstatus: Type.Optional(StatusSchema),\n\tblockedBy: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id this task depends on.\"), {\n\t\t\tmaxItems: TODO_MAX_BLOCKED_BY,\n\t\t\tdescription:\n\t\t\t\t\"Initial dependency ids, create only (update rejects it; use addBlockedBy/removeBlockedBy there); use only for real ordering constraints.\",\n\t\t}),\n\t),\n\titems: Type.Optional(\n\t\tType.Array(CreateManyItemSchema, {\n\t\t\tminItems: 1,\n\t\t\tmaxItems: TODO_MAX_BATCH_ITEMS,\n\t\t\tdescription:\n\t\t\t\t\"create_many only: up to 20 pending tasks, created atomically in input order. Use key and blockedByKeys for dependencies within this batch.\",\n\t\t}),\n\t),\n\taddBlockedBy: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id that must complete first.\"), {\n\t\t\tmaxItems: TODO_MAX_BLOCKED_BY,\n\t\t\tdescription: \"Dependency ids to add on update; dependencies must exist and cannot create cycles.\",\n\t\t}),\n\t),\n\tremoveBlockedBy: Type.Optional(Type.Array(TaskId(\"Dependency id to detach.\"), { maxItems: TODO_MAX_BLOCKED_BY })),\n\taddBlocks: Type.Optional(\n\t\tType.Array(TaskId(\"Existing task id that must wait for this one.\"), {\n\t\t\tmaxItems: TODO_MAX_BLOCKED_BY,\n\t\t\tdescription:\n\t\t\t\t\"Update only: ids of tasks that cannot start until this task completes (the reverse of addBlockedBy); targets must exist and cannot create cycles.\",\n\t\t}),\n\t),\n\tremoveBlocks: Type.Optional(Type.Array(TaskId(\"Dependent task id to release.\"), { maxItems: TODO_MAX_BLOCKED_BY })),\n\towner: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: TODO_MAX_OWNER_LENGTH,\n\t\t\tdescription:\n\t\t\t\t\"Optional owner or agent label for multi-agent coordination; on update an empty string removes it.\",\n\t\t}),\n\t),\n\tmetadata: Type.Optional(\n\t\tType.Record(Type.String({ maxLength: TODO_MAX_METADATA_KEY_LENGTH }), Type.Unknown(), {\n\t\t\tmaxProperties: TODO_MAX_METADATA_ENTRIES,\n\t\t\tdescription: \"Optional bounded JSON-like structured metadata; null values delete keys on update.\",\n\t\t}),\n\t),\n\tid: Type.Optional(TaskId(\"Task id, required for update, get, and delete.\")),\n\tincludeDeleted: Type.Optional(\n\t\tType.Boolean({\n\t\t\tdescription:\n\t\t\t\t\"Include deleted tombstones in unfiltered list output; an explicit status=deleted query includes them automatically.\",\n\t\t}),\n\t),\n\tlimit: Type.Optional(\n\t\tType.Integer({ minimum: 1, maximum: TODO_MAX_LIST_LIMIT, description: \"List page size (1 through 50).\" }),\n\t),\n\tafterId: Type.Optional(TaskId(\"List only tasks with ids greater than this id.\")),\n\tquery: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: TODO_MAX_LIST_QUERY_LENGTH,\n\t\t\tdescription: \"Case-insensitive subject and description search for list.\",\n\t\t}),\n\t),\n\tunblockedOnly: Type.Optional(Type.Boolean({ description: \"List only tasks with no unresolved dependencies.\" })),\n\tconfirm: Type.Optional(Type.Boolean({ description: \"clear only: must be true.\" })),\n\texpectedCount: Type.Optional(\n\t\tType.Integer({ minimum: 0, description: \"clear only: exact current task count required as a safety check.\" }),\n\t),\n});\n\nexport type TodoParams = Static<typeof TodoParamsSchema>;\n\nexport const EMPTY_TODO_STATE: TodoState = { items: [], nextId: 1 };\n"]}
|
|
@@ -2,6 +2,9 @@ import { type TodoDetails, type TodoItem, type TodoParams, type TodoState, type
|
|
|
2
2
|
type Operation = {
|
|
3
3
|
kind: "create";
|
|
4
4
|
id: number;
|
|
5
|
+
} | {
|
|
6
|
+
kind: "create_many";
|
|
7
|
+
ids: number[];
|
|
5
8
|
} | {
|
|
6
9
|
kind: "update";
|
|
7
10
|
id: number;
|
|
@@ -9,22 +12,30 @@ type Operation = {
|
|
|
9
12
|
to: TodoStatus;
|
|
10
13
|
/** Other tasks auto-demoted from in_progress → pending to keep exactly one active. */
|
|
11
14
|
demotedIds?: number[];
|
|
15
|
+
/** Active tasks demoted because a new dependency is still incomplete. */
|
|
16
|
+
blockedIds?: number[];
|
|
12
17
|
/** Pending dependents left fully unblocked by an update to status deleted. */
|
|
13
18
|
releasedIds?: number[];
|
|
14
19
|
/** Soft note when the list is fully closed without a verification-style task. */
|
|
15
20
|
verificationNudge?: boolean;
|
|
16
|
-
} | {
|
|
17
|
-
kind: "delete";
|
|
18
|
-
id: number;
|
|
19
|
-
subject: string;
|
|
20
|
-
releasedIds?: number[];
|
|
21
21
|
} | {
|
|
22
22
|
kind: "list";
|
|
23
23
|
status?: TodoStatus;
|
|
24
24
|
includeDeleted: boolean;
|
|
25
|
+
limit: number;
|
|
26
|
+
afterId?: number;
|
|
27
|
+
query?: string;
|
|
28
|
+
unblockedOnly: boolean;
|
|
29
|
+
/** Keep the historical no-options list text stable. */
|
|
30
|
+
legacyOutput: boolean;
|
|
25
31
|
} | {
|
|
26
32
|
kind: "get";
|
|
27
33
|
item: TodoItem;
|
|
34
|
+
} | {
|
|
35
|
+
kind: "delete";
|
|
36
|
+
id: number;
|
|
37
|
+
subject: string;
|
|
38
|
+
releasedIds?: number[];
|
|
28
39
|
} | {
|
|
29
40
|
kind: "clear";
|
|
30
41
|
count: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../../src/extensions/todo/state.ts"],"names":[],"mappings":"AACA,OAAO,EAGN,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,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;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,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,iFAAiF;IACjF,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC3B,GACD;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,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,UAAU,CAAC;IAAC,cAAc,EAAE,OAAO,CAAA;CAAE,GAC9D;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAKtC,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;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,GAAG,SAAS,CAYvD;AAyHD,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;AA6BD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,GAAG,cAAc,CAuPtF;AAQD,wBAAgB,qBAAqB,CAAC,GAAG,EAAE;IAAE,cAAc,EAAE;QAAE,SAAS,IAAI,QAAQ,CAAC,OAAO,CAAC,CAAA;KAAE,CAAA;CAAE,GAAG,SAAS,CAiB5G;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,GAAG,WAAW,CAOjF;AAUD,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,GAAG,MAAM,CAiChF","sourcesContent":["import { LIST_DISPLAY_MAX_ITEMS, TODO_TOOL_NAME } from \"./constants.ts\";\nimport {\n\tEMPTY_TODO_STATE,\n\ttype TodoAction,\n\ttype TodoDetails,\n\ttype TodoItem,\n\ttype TodoParams,\n\ttype TodoState,\n\ttype TodoStatus,\n} from \"./schema.ts\";\n\ntype Operation =\n\t| { kind: \"create\"; id: 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/** 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| { kind: \"delete\"; id: number; subject: string; releasedIds?: number[] }\n\t| { kind: \"list\"; status?: TodoStatus; includeDeleted: boolean }\n\t| { kind: \"get\"; item: TodoItem }\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;\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\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) clone.metadata = { ...item.metadata };\n\t\t\telse 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\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\"]),\n\tget: new Set([\"id\"]),\n\tdelete: new Set([\"id\"]),\n\tclear: new Set<string>(),\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 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[] | undefined, currentId?: number): string | undefined {\n\tif (!deps?.length) return 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[] | undefined): string | undefined {\n\tif (!deps?.length) return 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 any task whose deps change in nextDepsById can reach itself through blockedBy edges. */\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\tconst seen = new Set<number>();\n\tconst stack = new Set<number>();\n\n\tfunction visit(node: number): boolean {\n\t\tif (stack.has(node)) return true;\n\t\tif (seen.has(node)) return false;\n\t\tseen.add(node);\n\t\tstack.add(node);\n\t\tfor (const dep of depsById.get(node) ?? []) {\n\t\t\tif (visit(dep)) return true;\n\t\t}\n\t\tstack.delete(node);\n\t\treturn false;\n\t}\n\n\tfor (const id of nextDepsById.keys()) {\n\t\tif (visit(id)) return true;\n\t}\n\treturn false;\n}\n\nexport function applyTodoMutation(input: TodoState, params: TodoParams): MutationResult {\n\tconst state = cloneState(input);\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\tconst subject = params.subject?.trim();\n\t\t\tif (!subject) return error(state, \"subject required for create\");\n\t\t\tif (!params.description?.trim()) {\n\t\t\t\treturn error(state, \"description required for create: state what done means for this task\");\n\t\t\t}\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 dependencyError = validateDependencies(state, params.blockedBy);\n\t\t\tif (dependencyError) return error(state, dependencyError);\n\n\t\t\tconst item: TodoItem = {\n\t\t\t\tid: state.nextId,\n\t\t\t\tsubject,\n\t\t\t\tstatus: \"pending\",\n\t\t\t\tdescription: params.description,\n\t\t\t};\n\t\t\tif (params.activeForm) item.activeForm = params.activeForm;\n\t\t\tif (params.blockedBy?.length) item.blockedBy = Array.from(new Set<number>(params.blockedBy));\n\t\t\tif (params.owner) item.owner = params.owner;\n\t\t\tif (params.metadata) item.metadata = { ...params.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 \"update\": {\n\t\t\tif (params.id === undefined) 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\tif (params.subject !== undefined && !params.subject.trim()) return error(state, \"subject cannot be empty\");\n\n\t\t\tconst edgeChange =\n\t\t\t\t(params.addBlockedBy?.length ?? 0) > 0 ||\n\t\t\t\t(params.removeBlockedBy?.length ?? 0) > 0 ||\n\t\t\t\t(params.addBlocks?.length ?? 0) > 0 ||\n\t\t\t\t(params.removeBlocks?.length ?? 0) > 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\t// `status: deleted` is the update form of delete; keep its dependency\n\t\t\t// cleanup identical to the dedicated delete action. Other edits do not\n\t\t\t// combine with it: a tombstone is immutable, so applying them would\n\t\t\t// silently produce an unreachable revision.\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\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 (params.removeBlockedBy?.length) {\n\t\t\t\tconst remove = new Set(params.removeBlockedBy);\n\t\t\t\tnextDeps = nextDeps.filter((id) => !remove.has(id));\n\t\t\t}\n\t\t\tif (params.addBlockedBy?.length) {\n\t\t\t\tconst dependencyError = validateDependencies(state, params.addBlockedBy, current.id);\n\t\t\t\tif (dependencyError) return error(state, dependencyError);\n\t\t\t\tfor (const dep of params.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 (params.removeBlocks?.length) {\n\t\t\t\tconst release = new Set(params.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 (params.addBlocks?.length) {\n\t\t\t\tfor (const targetId of params.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 only status *changes*: an already-active task may take\n\t\t\t// on a new incomplete dependency — a prerequisite discovered mid-work.\n\t\t\t// The overlay flags it as \"deps incomplete\" instead.\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\n\t\t\tif (params.metadata !== undefined) {\n\t\t\t\tconst merged = { ...(current.metadata ?? {}) };\n\t\t\t\tfor (const [key, value] of Object.entries(params.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) updated.metadata = merged;\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 task;\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\t\t\titems[index] = updated;\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\tif (nextStatus === \"in_progress\") {\n\t\t\t\tfor (let i = 0; i < items.length; i++) {\n\t\t\t\t\tconst item = items[i];\n\t\t\t\t\tif (item.id === updated.id) continue;\n\t\t\t\t\tif (item.status !== \"in_progress\") continue;\n\t\t\t\t\titems[i] = { ...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 newlyCompleted = current.status !== \"completed\" && nextStatus === \"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: updated.id,\n\t\t\t\t\tfrom: current.status,\n\t\t\t\t\tto: updated.status,\n\t\t\t\t\t...(demotedIds.length ? { demotedIds } : {}),\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\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},\n\t\t\t};\n\n\t\tcase \"get\": {\n\t\t\tif (params.id === undefined) 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 (params.id === undefined) 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\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\treturn error(state, `unknown action: ${(params as { action?: unknown }).action}`);\n}\n\nfunction isTodoDetails(value: unknown): value is TodoDetails {\n\tif (!value || typeof value !== \"object\") return false;\n\tconst record = value as Record<string, unknown>;\n\treturn Array.isArray(record.items) && typeof record.nextId === \"number\";\n}\n\nexport function replayTodosFromBranch(ctx: { sessionManager: { getBranch(): Iterable<unknown> } }): TodoState {\n\t// Latest todo toolResult wins; scan tail → head so long branches stop early.\n\tconst branch = Array.from(ctx.sessionManager.getBranch());\n\tfor (let i = branch.length - 1; i >= 0; i--) {\n\t\tconst item = branch[i] as {\n\t\t\ttype?: string;\n\t\t\tmessage?: { role?: string; toolName?: string; details?: unknown };\n\t\t};\n\t\tif (item.type !== \"message\") continue;\n\t\tif (item.message?.role !== \"toolResult\" || item.message.toolName !== TODO_TOOL_NAME) continue;\n\t\tif (!isTodoDetails(item.message.details)) continue;\n\t\treturn cloneState({\n\t\t\titems: item.message.details.items,\n\t\t\tnextId: item.message.details.nextId,\n\t\t});\n\t}\n\treturn cloneState(EMPTY_TODO_STATE);\n}\n\nexport function buildTodoDetails(params: TodoParams, next: TodoState): TodoDetails {\n\treturn {\n\t\taction: params.action,\n\t\tparams: params as Record<string, unknown>,\n\t\titems: cloneState(next).items,\n\t\tnextId: next.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\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 ? `Created #${item.id}: ${item.subject} (pending)` : `Created #${operation.id}`;\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\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}: ${operation.subject}${formatReleasedSuffix(operation.releasedIds)}`;\n\t\tcase \"clear\":\n\t\t\treturn `Cleared ${operation.count} tasks`;\n\t\tcase \"list\": {\n\t\t\tlet view = state.items;\n\t\t\t// An explicit deleted-status query is sufficient intent to include tombstones.\n\t\t\tif (!operation.includeDeleted && operation.status !== \"deleted\")\n\t\t\t\tview = view.filter((item) => item.status !== \"deleted\");\n\t\t\tif (operation.status) view = view.filter((item) => item.status === operation.status);\n\t\t\treturn formatBoundedList(view, state, operation.status);\n\t\t}\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(view: TodoItem[], state: TodoState, statusFilter: TodoStatus | undefined): string {\n\tif (!view.length) return \"No tasks\";\n\tconst shown = view.slice(0, LIST_DISPLAY_MAX_ITEMS);\n\tconst lines = shown.map((item) => formatListItem(item, state));\n\tconst omitted = view.length - shown.length;\n\tif (omitted > 0) {\n\t\t// Do not suggest a filter that is already applied.\n\t\tconst hint = statusFilter ? \"use get with id= for details\" : \"narrow with status= or use get with id=\";\n\t\tlines.push(`… and ${omitted} more (${shown.length} of ${view.length} shown); ${hint}.`);\n\t}\n\treturn lines.join(\"\\n\");\n}\n\nfunction formatListItem(item: TodoItem, state: TodoState): string {\n\tconst unresolved = unresolvedDependencyIds(state, item);\n\tconst deps = unresolved.length ? ` blockedBy=${unresolved.map((id) => `#${id}`).join(\",\")}` : \"\";\n\tconst owner = item.owner ? ` @${item.owner}` : \"\";\n\tconst active = item.status === \"in_progress\" && item.activeForm ? ` (${item.activeForm})` : \"\";\n\treturn `[${item.status}] #${item.id} ${item.subject}${active}${owner}${deps}`;\n}\n\nfunction formatDetailItem(item: TodoItem, state: TodoState): string {\n\tconst lines = [`#${item.id} [${item.status}] ${item.subject}`];\n\tif (item.description) lines.push(`description: ${item.description}`);\n\tif (item.activeForm) lines.push(`activeForm: ${item.activeForm}`);\n\tif (item.blockedBy?.length) lines.push(`blockedBy: ${item.blockedBy.map((id) => `#${id}`).join(\", \")}`);\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: ${blocks.map((id) => `#${id}`).join(\", \")}`);\n\tif (item.owner) lines.push(`owner: ${item.owner}`);\n\treturn lines.join(\"\\n\");\n}\n"]}
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../../src/extensions/todo/state.ts"],"names":[],"mappings":"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","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"]}
|