@bumpyclock/pi-tasque 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +168 -140
  2. package/package.json +24 -6
  3. package/src/bridge/bridge-tool.ts +10 -13
  4. package/src/bridge/import-tsq.ts +5 -30
  5. package/src/bridge/promote-todo.ts +1 -4
  6. package/src/bridge/types.ts +11 -12
  7. package/src/durable-tasks/bulk-contract.ts +176 -0
  8. package/src/durable-tasks/cache.ts +1 -4
  9. package/src/durable-tasks/change-command-builder.ts +315 -0
  10. package/src/durable-tasks/handoff-guard.ts +329 -0
  11. package/src/durable-tasks/project.ts +71 -0
  12. package/src/durable-tasks/runner.ts +1 -4
  13. package/src/durable-tasks/status.ts +20 -5
  14. package/src/durable-tasks/task-mappers.ts +160 -0
  15. package/src/durable-tasks/task-schema.ts +193 -0
  16. package/src/durable-tasks/task-tool.ts +197 -0
  17. package/src/durable-tasks/task-validation.ts +123 -0
  18. package/src/durable-tasks/tools-bulk.ts +141 -0
  19. package/src/durable-tasks/tools-change.ts +111 -382
  20. package/src/durable-tasks/tools-claim.ts +15 -43
  21. package/src/durable-tasks/tools-handoff.ts +95 -0
  22. package/src/durable-tasks/tools-query.ts +58 -29
  23. package/src/durable-tasks/tools-spec.ts +230 -0
  24. package/src/durable-tasks/tools-tree-create.ts +221 -0
  25. package/src/guidelines/internal-tools.ts +33 -0
  26. package/src/guidelines/task.ts +5 -0
  27. package/src/guidelines/todo.ts +5 -0
  28. package/src/index.ts +2 -13
  29. package/src/session-todos/state/replay.ts +10 -13
  30. package/src/session-todos/todo-overlay.ts +1 -1
  31. package/src/session-todos/todo.ts +11 -15
  32. package/src/session-todos/tool/types.ts +7 -7
  33. package/src/shared/error-utils.ts +29 -0
  34. package/src/shared/validation.ts +25 -0
@@ -0,0 +1,193 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { type Static, Type } from "typebox";
3
+ import { BULK_ITEM_ACTIONS } from "./bulk-contract.js";
4
+
5
+ export const TASK_TOOL_NAME = "task";
6
+
7
+ export const TASK_ACTIONS = [
8
+ "doctor",
9
+ "find",
10
+ "show",
11
+ "deps",
12
+ "notes",
13
+ "similar",
14
+ "create",
15
+ "note",
16
+ "finish",
17
+ "reopen",
18
+ "defer",
19
+ "start",
20
+ "claim",
21
+ "block",
22
+ "unblock",
23
+ "order",
24
+ "unorder",
25
+ "spec",
26
+ "mark_planned",
27
+ "bulk",
28
+ "create_tree",
29
+ "handoff_check",
30
+ "link",
31
+ "list_links",
32
+ "promote",
33
+ "import",
34
+ ] as const;
35
+
36
+ export const FIND_TARGETS = ["ready", "open"] as const;
37
+ export const VIEW_MODES = ["list", "tree"] as const;
38
+ export const SPEC_MODES = ["show", "check", "set", "update"] as const;
39
+ export const BRIDGE_DESTINATIONS = ["todo"] as const;
40
+
41
+ export type TaskAction = (typeof TASK_ACTIONS)[number];
42
+
43
+ const BulkItemParamsSchema = Type.Object({
44
+ action: StringEnum(BULK_ITEM_ACTIONS, {
45
+ description:
46
+ "Bulk item action: start, finish, reopen, defer, note, or mark_planned.",
47
+ }),
48
+ task: Type.String({ description: "Durable task id for this bulk item." }),
49
+ because: Type.Optional(
50
+ Type.String({
51
+ description:
52
+ "Note/reason text. Required for note; optional for finish/defer.",
53
+ }),
54
+ ),
55
+ });
56
+
57
+ const CreateTreeNodeParamsSchema = Type.Object({
58
+ title: Type.String({ description: "Durable task title for this node." }),
59
+ kind: Type.String({ description: "Durable task kind for this node." }),
60
+ priority: Type.Integer({
61
+ description: "Durable task priority for this node.",
62
+ }),
63
+ description: Type.Optional(
64
+ Type.String({ description: "Task description for this node." }),
65
+ ),
66
+ planned: Type.Optional(
67
+ Type.Boolean({ description: "Mark this node planned." }),
68
+ ),
69
+ needsPlan: Type.Optional(
70
+ Type.Boolean({ description: "Mark this node as needing planning." }),
71
+ ),
72
+ children: Type.Optional(
73
+ Type.Array(
74
+ Type.Unknown({
75
+ description:
76
+ "Child create-tree nodes with the same shape: { title, kind, priority, description?, planned?, needsPlan?, children? }.",
77
+ }),
78
+ { description: "Nested child task nodes." },
79
+ ),
80
+ ),
81
+ });
82
+
83
+ export const TaskParamsSchema = Type.Object(
84
+ {
85
+ action: StringEnum(TASK_ACTIONS, {
86
+ description: "Durable task action to run.",
87
+ }),
88
+ task: Type.Optional(
89
+ Type.String({
90
+ description: "Durable task id for existing tasks, or title for create.",
91
+ }),
92
+ ),
93
+ tasks: Type.Optional(
94
+ StringEnum(FIND_TARGETS, {
95
+ description: "Task set for find actions.",
96
+ }),
97
+ ),
98
+ view: Type.Optional(
99
+ StringEnum(VIEW_MODES, {
100
+ description: "Find output view.",
101
+ }),
102
+ ),
103
+ lane: Type.Optional(
104
+ Type.String({ description: "Ready-task lane, e.g. planning or coding." }),
105
+ ),
106
+ for: Type.Optional(
107
+ Type.String({ description: "Assignee or owner for claim/find/import." }),
108
+ ),
109
+ query: Type.Optional(
110
+ Type.String({ description: "Search text for similar task lookup." }),
111
+ ),
112
+ with: Type.Optional(
113
+ Type.Array(
114
+ Type.String({ description: "Extra context to include, e.g. spec." }),
115
+ ),
116
+ ),
117
+ kind: Type.Optional(Type.String({ description: "Durable task kind." })),
118
+ priority: Type.Optional(
119
+ Type.Integer({ description: "Durable task priority." }),
120
+ ),
121
+ description: Type.Optional(
122
+ Type.String({ description: "Task description for create/promote." }),
123
+ ),
124
+ under: Type.Optional(
125
+ Type.String({
126
+ description: "Parent durable task id for create/promote.",
127
+ }),
128
+ ),
129
+ planned: Type.Optional(
130
+ Type.Boolean({ description: "Mark created/promoted task planned." }),
131
+ ),
132
+ needsPlan: Type.Optional(
133
+ Type.Boolean({
134
+ description: "Mark created/promoted task as needing planning.",
135
+ }),
136
+ ),
137
+ because: Type.Optional(
138
+ Type.String({
139
+ description: "Note or reason text for lifecycle actions.",
140
+ }),
141
+ ),
142
+ by: Type.Optional(
143
+ Type.String({
144
+ description: "Blocking durable task id for block/unblock.",
145
+ }),
146
+ ),
147
+ after: Type.Optional(
148
+ Type.String({
149
+ description: "Earlier durable task id for order/unorder.",
150
+ }),
151
+ ),
152
+ start: Type.Optional(
153
+ Type.Boolean({
154
+ description: "Start task while claiming. Defaults true.",
155
+ }),
156
+ ),
157
+ requireSpec: Type.Optional(
158
+ Type.Boolean({ description: "Require an attached spec before claim." }),
159
+ ),
160
+ todo: Type.Optional(
161
+ Type.Union([
162
+ Type.Boolean({ description: "Create a linked todo for claim." }),
163
+ Type.Integer({ description: "Session todo id for link/promote." }),
164
+ ]),
165
+ ),
166
+ mode: Type.Optional(
167
+ StringEnum(SPEC_MODES, {
168
+ description: "Spec operation mode for spec action.",
169
+ }),
170
+ ),
171
+ text: Type.Optional(
172
+ Type.String({
173
+ description: "Spec text content for spec set/update.",
174
+ }),
175
+ ),
176
+ to: Type.Optional(
177
+ StringEnum(BRIDGE_DESTINATIONS, {
178
+ description: "Bridge destination for import.",
179
+ }),
180
+ ),
181
+ items: Type.Optional(
182
+ Type.Array(BulkItemParamsSchema, {
183
+ description:
184
+ "Bulk lifecycle items. Each item has { action, task, because? }.",
185
+ minItems: 1,
186
+ }),
187
+ ),
188
+ root: Type.Optional(CreateTreeNodeParamsSchema),
189
+ },
190
+ { additionalProperties: false },
191
+ );
192
+
193
+ export type TaskParams = Static<typeof TaskParamsSchema>;
@@ -0,0 +1,197 @@
1
+ import {
2
+ defineTool,
3
+ type AgentToolResult,
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { executeTaskBridge } from "../bridge/bridge-tool.js";
8
+ import type { TaskBridgeHandlers } from "../bridge/types.js";
9
+ import {
10
+ TASK_PROMPT_GUIDELINES,
11
+ TASK_PROMPT_SNIPPET,
12
+ } from "../guidelines/task.js";
13
+ import { isRecord } from "../shared/error-utils.js";
14
+ import { errorToolDetails, textToolResult } from "../shared/tool-result.js";
15
+ import { importTsqHandler } from "../bridge/import-tsq.js";
16
+ import { promoteTodoHandler } from "../bridge/promote-todo.js";
17
+ import type { BulkItem, CreateTreeNode } from "./bulk-contract.js";
18
+ import { resolveProjectRoot } from "./project.js";
19
+ import {
20
+ TASK_TOOL_NAME,
21
+ TaskParamsSchema,
22
+ type TaskAction,
23
+ type TaskParams,
24
+ } from "./task-schema.js";
25
+ import { actionUsesTasque, validateTaskParams } from "./task-validation.js";
26
+ import {
27
+ toBridgeParams,
28
+ toChangeParams,
29
+ toClaimParams,
30
+ toQueryParams,
31
+ } from "./task-mappers.js";
32
+ import { executeBulk } from "./tools-bulk.js";
33
+ import { executeTsqChange, executeTsqMarkPlanned } from "./tools-change.js";
34
+ import { executeTsqClaim } from "./tools-claim.js";
35
+ import { executeHandoffCheck } from "./tools-handoff.js";
36
+ import { executeTsqQuery } from "./tools-query.js";
37
+ import { executeTsqSpec, type SpecMode } from "./tools-spec.js";
38
+ import { executeCreateTree } from "./tools-tree-create.js";
39
+
40
+ // --- Re-exports for backward compatibility ---
41
+ export { TASK_TOOL_NAME, TaskParamsSchema, type TaskParams, type TaskAction } from "./task-schema.js";
42
+
43
+ const DEFAULT_HANDLERS: TaskBridgeHandlers = {
44
+ promote_todo: promoteTodoHandler,
45
+ import_tsq: importTsqHandler,
46
+ };
47
+
48
+ export function registerTaskTool(pi: ExtensionAPI): void {
49
+ pi.registerTool(
50
+ defineTool({
51
+ name: TASK_TOOL_NAME,
52
+ label: "Task",
53
+ description:
54
+ "Manage durable project tasks: find, show, create, claim, specs, bulk changes, handoff checks, lifecycle, dependencies, and todo links.",
55
+ promptSnippet: TASK_PROMPT_SNIPPET,
56
+ promptGuidelines: TASK_PROMPT_GUIDELINES,
57
+ parameters: TaskParamsSchema,
58
+ executionMode: "sequential",
59
+
60
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
61
+ return executeTaskTool(pi, params as TaskParams, signal, ctx);
62
+ },
63
+ }),
64
+ );
65
+ }
66
+
67
+ export async function executeTaskTool(
68
+ pi: ExtensionAPI,
69
+ params: TaskParams,
70
+ signal: AbortSignal | undefined,
71
+ ctx: ExtensionContext,
72
+ ): Promise<AgentToolResult<unknown>> {
73
+ const validation = validateTaskParams(params);
74
+ if (!validation.ok) {
75
+ return validationErrorResult(validation.message);
76
+ }
77
+
78
+ const needsProject = actionUsesTasque(params.action);
79
+ let projectRoot = ctx.cwd;
80
+ if (needsProject) {
81
+ try {
82
+ projectRoot = await resolveProjectRoot(
83
+ pi,
84
+ ctx.cwd,
85
+ signal === undefined ? {} : { signal },
86
+ );
87
+ } catch (error) {
88
+ return errorResult("project_root_error", getErrorMessage(error), {
89
+ action: params.action,
90
+ cwd: ctx.cwd,
91
+ error: serializeError(error),
92
+ });
93
+ }
94
+ }
95
+
96
+ const taskCtx = { ...ctx, cwd: projectRoot };
97
+ const result = await dispatchTaskAction(pi, params, signal, taskCtx);
98
+ return params.action === "handoff_check"
99
+ ? result
100
+ : addProjectDetails(result, projectRoot);
101
+ }
102
+
103
+ async function dispatchTaskAction(
104
+ pi: ExtensionAPI,
105
+ params: TaskParams,
106
+ signal: AbortSignal | undefined,
107
+ ctx: ExtensionContext,
108
+ ): Promise<AgentToolResult<unknown>> {
109
+ switch (params.action) {
110
+ case "doctor":
111
+ case "find":
112
+ case "show":
113
+ case "deps":
114
+ case "notes":
115
+ case "similar":
116
+ return executeTsqQuery(pi, toQueryParams(params), signal, ctx);
117
+ case "create":
118
+ case "note":
119
+ case "finish":
120
+ case "reopen":
121
+ case "defer":
122
+ case "start":
123
+ case "block":
124
+ case "unblock":
125
+ case "order":
126
+ case "unorder":
127
+ return executeTsqChange(pi, toChangeParams(params), signal, ctx);
128
+ case "claim":
129
+ return executeTsqClaim(pi, toClaimParams(params), signal, ctx);
130
+ case "spec":
131
+ return executeTsqSpec(
132
+ pi,
133
+ { id: params.task, mode: params.mode as SpecMode, text: params.text },
134
+ signal,
135
+ ctx,
136
+ );
137
+ case "mark_planned":
138
+ return executeTsqMarkPlanned(pi, params.task!, signal, ctx);
139
+ case "bulk":
140
+ return executeBulk(pi, params.items as BulkItem[], signal, ctx);
141
+ case "create_tree":
142
+ return executeCreateTree(pi, params.root as CreateTreeNode, signal, ctx);
143
+ case "handoff_check":
144
+ return executeHandoffCheck(pi, signal, ctx);
145
+ case "link":
146
+ case "list_links":
147
+ case "promote":
148
+ case "import":
149
+ return executeTaskBridge(
150
+ pi,
151
+ toBridgeParams(params),
152
+ signal,
153
+ ctx,
154
+ DEFAULT_HANDLERS,
155
+ );
156
+ }
157
+ }
158
+
159
+ function validationErrorResult(message: string): AgentToolResult<unknown> {
160
+ return errorResult("validation_error", message);
161
+ }
162
+
163
+ function errorResult(
164
+ code: string,
165
+ message: string,
166
+ details: Record<string, unknown> = {},
167
+ ): AgentToolResult<unknown> {
168
+ return textToolResult(
169
+ `Error: ${message}`,
170
+ errorToolDetails({ code, message, details }),
171
+ );
172
+ }
173
+
174
+ function addProjectDetails(
175
+ result: AgentToolResult<unknown>,
176
+ projectRoot: string,
177
+ ): AgentToolResult<unknown> {
178
+ const details = isRecord(result.details)
179
+ ? { ...result.details, projectRoot }
180
+ : { projectRoot };
181
+ return { ...result, details };
182
+ }
183
+
184
+ function getErrorMessage(error: unknown): string {
185
+ return error instanceof Error ? error.message : String(error);
186
+ }
187
+
188
+ function serializeError(error: unknown): Record<string, unknown> {
189
+ if (error instanceof Error) {
190
+ return {
191
+ name: error.name,
192
+ message: error.message,
193
+ ...(error.stack === undefined ? {} : { stack: error.stack }),
194
+ };
195
+ }
196
+ return { value: String(error) };
197
+ }
@@ -0,0 +1,123 @@
1
+ import {
2
+ fieldRequired,
3
+ requireStringField,
4
+ } from "../shared/validation.js";
5
+ import {
6
+ validateBulkItems,
7
+ validateCreateTreeNode,
8
+ } from "./bulk-contract.js";
9
+ import type { TaskAction, TaskParams } from "./task-schema.js";
10
+
11
+ export function validateTaskParams(
12
+ params: TaskParams,
13
+ ): { readonly ok: true } | { readonly ok: false; readonly message: string } {
14
+ switch (params.action) {
15
+ case "find":
16
+ if (params.view === "tree") return { ok: true };
17
+ if (params.tasks === undefined) return fieldRequired("tasks");
18
+ return { ok: true };
19
+ case "show":
20
+ case "deps":
21
+ case "notes":
22
+ case "note":
23
+ case "finish":
24
+ case "reopen":
25
+ case "defer":
26
+ case "start":
27
+ case "claim":
28
+ case "import":
29
+ return requireStringField(params.task, "task");
30
+ case "create": {
31
+ const task = requireStringField(params.task, "task");
32
+ if (!task.ok) return task;
33
+ const kind = requireStringField(params.kind, "kind");
34
+ if (!kind.ok) return kind;
35
+ return typeof params.priority === "number"
36
+ ? { ok: true }
37
+ : fieldRequired("priority");
38
+ }
39
+ case "similar":
40
+ return requireStringField(params.query, "query");
41
+ case "block":
42
+ case "unblock": {
43
+ const task = requireStringField(params.task, "task");
44
+ if (!task.ok) return task;
45
+ return requireStringField(params.by, "by");
46
+ }
47
+ case "order":
48
+ case "unorder": {
49
+ const task = requireStringField(params.task, "task");
50
+ if (!task.ok) return task;
51
+ return requireStringField(params.after, "after");
52
+ }
53
+ case "link": {
54
+ const todo = requireTodoId(params.todo);
55
+ if (!todo.ok) return todo;
56
+ return requireStringField(params.task, "task");
57
+ }
58
+ case "promote":
59
+ return requireTodoId(params.todo);
60
+ case "mark_planned":
61
+ return requireStringField(params.task, "task");
62
+ case "spec": {
63
+ const task = requireStringField(params.task, "task");
64
+ if (!task.ok) return task;
65
+ const mode = params.mode as string | undefined;
66
+ if (mode === undefined || mode.trim().length === 0)
67
+ return fieldRequired("mode");
68
+ const isRead = mode === "show" || mode === "check";
69
+ const isWrite = mode === "set" || mode === "update";
70
+ if (!isRead && !isWrite)
71
+ return {
72
+ ok: false,
73
+ message: "mode must be show, check, set, or update",
74
+ };
75
+ if (isRead && params.text !== undefined)
76
+ return {
77
+ ok: false,
78
+ message: `spec ${mode} does not accept text`,
79
+ };
80
+ if (isWrite) {
81
+ const text = params.text?.trim();
82
+ if (text === undefined || text.length === 0)
83
+ return {
84
+ ok: false,
85
+ message: `spec ${mode} requires text`,
86
+ };
87
+ }
88
+ return { ok: true };
89
+ }
90
+ case "bulk":
91
+ return validateBulkItems(params.items);
92
+ case "create_tree":
93
+ return validateCreateTreeNode(params.root);
94
+ case "handoff_check":
95
+ case "doctor":
96
+ case "list_links":
97
+ return { ok: true };
98
+ }
99
+ }
100
+
101
+ export function actionUsesTasque(action: TaskAction): boolean {
102
+ return (
103
+ action !== "link" && action !== "list_links" && action !== "handoff_check"
104
+ );
105
+ }
106
+
107
+ export function hasWith(params: TaskParams, value: string): boolean {
108
+ return Array.isArray(params.with) && params.with.includes(value);
109
+ }
110
+
111
+ export function getTodoId(
112
+ value: boolean | number | undefined,
113
+ ): number | undefined {
114
+ return typeof value === "number" ? value : undefined;
115
+ }
116
+
117
+ export function requireTodoId(
118
+ value: boolean | number | undefined,
119
+ ): { readonly ok: true } | { readonly ok: false; readonly message: string } {
120
+ return typeof value === "number" && Number.isInteger(value) && value >= 1
121
+ ? { ok: true }
122
+ : fieldRequired("todo");
123
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Bulk lifecycle executor for durable tasks (tsq-6.2).
3
+ *
4
+ * Runs validated BulkItem[] sequentially with fail-fast semantics.
5
+ * Reuses executeTsqChange / executeTsqMarkPlanned — no raw CLI argv
6
+ * in the public contract.
7
+ */
8
+
9
+ import type {
10
+ AgentToolResult,
11
+ ExtensionAPI,
12
+ ExtensionContext,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import type { BulkItem, BulkItemAction, BulkResult } from "./bulk-contract.js";
15
+ import { isRecord } from "../shared/error-utils.js";
16
+ import type { StandardToolDetails } from "../shared/tool-result.js";
17
+ import { okToolDetails, textToolResult } from "../shared/tool-result.js";
18
+ import {
19
+ executeTsqChange,
20
+ executeTsqMarkPlanned,
21
+ type TsqChangeAction,
22
+ } from "./tools-change.js";
23
+
24
+ // ── Action mapping ─────────────────────────────────────────────────
25
+
26
+ const BULK_TO_CHANGE_ACTION: Record<
27
+ Exclude<BulkItemAction, "mark_planned">,
28
+ TsqChangeAction
29
+ > = {
30
+ start: "start",
31
+ finish: "done",
32
+ reopen: "reopen",
33
+ defer: "defer",
34
+ note: "note",
35
+ };
36
+
37
+ // ── Public executor ────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Execute a validated bulk lifecycle operation.
41
+ *
42
+ * Items run sequentially via the existing mutation queue.
43
+ * On first failure, remaining items are marked skipped (no rollback).
44
+ * Result details are always `ok: true` — failure info lives in `BulkResult.failed`.
45
+ */
46
+ export async function executeBulk(
47
+ pi: ExtensionAPI,
48
+ items: readonly BulkItem[],
49
+ signal: AbortSignal | undefined,
50
+ ctx: Pick<ExtensionContext, "cwd">,
51
+ ): Promise<AgentToolResult<StandardToolDetails<BulkResult>>> {
52
+ const completed: string[] = [];
53
+ let failed: BulkResult["failed"];
54
+ const skipped: string[] = [];
55
+
56
+ for (let i = 0; i < items.length; i++) {
57
+ const item = items[i]!;
58
+ const result = await executeOneItem(pi, item, signal, ctx);
59
+
60
+ if (isResultOk(result)) {
61
+ completed.push(item.task);
62
+ } else {
63
+ failed = { task: item.task, error: extractErrorMessage(result) };
64
+ for (let j = i + 1; j < items.length; j++) {
65
+ skipped.push(items[j]!.task);
66
+ }
67
+ break;
68
+ }
69
+ }
70
+
71
+ const bulkResult: BulkResult = {
72
+ completed,
73
+ ...(failed !== undefined ? { failed } : {}),
74
+ skipped,
75
+ };
76
+
77
+ return textToolResult(
78
+ formatBulkText(bulkResult, items.length),
79
+ okToolDetails(bulkResult),
80
+ );
81
+ }
82
+
83
+ // ── Single-item dispatch ───────────────────────────────────────────
84
+
85
+ function executeOneItem(
86
+ pi: ExtensionAPI,
87
+ item: BulkItem,
88
+ signal: AbortSignal | undefined,
89
+ ctx: Pick<ExtensionContext, "cwd">,
90
+ ): Promise<AgentToolResult<unknown>> {
91
+ if (item.action === "mark_planned") {
92
+ return executeTsqMarkPlanned(pi, item.task, signal, ctx);
93
+ }
94
+
95
+ const action = BULK_TO_CHANGE_ACTION[item.action];
96
+ const params = buildChangeParams(action, item);
97
+ return executeTsqChange(pi, params, signal, ctx);
98
+ }
99
+
100
+ function buildChangeParams(
101
+ action: TsqChangeAction,
102
+ item: BulkItem,
103
+ ): { action: TsqChangeAction; id: string; note?: string } {
104
+ if (item.because !== undefined) {
105
+ return { action, id: item.task, note: item.because };
106
+ }
107
+ return { action, id: item.task };
108
+ }
109
+
110
+ // ── Result inspection ──────────────────────────────────────────────
111
+
112
+ function isResultOk(result: AgentToolResult<unknown>): boolean {
113
+ const d = result.details;
114
+ return isRecord(d) && d.ok === true;
115
+ }
116
+
117
+ function extractErrorMessage(result: AgentToolResult<unknown>): string {
118
+ const d = result.details;
119
+ if (isRecord(d) && isRecord(d.error)) {
120
+ const msg = d.error.message;
121
+ if (typeof msg === "string") return msg;
122
+ }
123
+ return "unknown error";
124
+ }
125
+
126
+ // ── Formatting ─────────────────────────────────────────────────────
127
+
128
+ function formatBulkText(result: BulkResult, total: number): string {
129
+ if (result.failed === undefined) {
130
+ return `Bulk: ${result.completed.length}/${total} completed`;
131
+ }
132
+
133
+ const parts = [`Bulk: ${result.completed.length}/${total} completed`];
134
+ parts.push("1 failed");
135
+ if (result.skipped.length > 0) {
136
+ parts.push(`${result.skipped.length} skipped`);
137
+ }
138
+ return `${parts.join(", ")}. Failed: ${result.failed.task} \u2014 ${result.failed.error}`;
139
+ }
140
+
141
+ // ── Utilities ──────────────────────────────────────────────────────