@bumpyclock/pi-tasque 0.1.0 → 0.2.0

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.
@@ -0,0 +1,144 @@
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 type { StandardToolDetails } from "../shared/tool-result.js";
16
+ import { okToolDetails, textToolResult } from "../shared/tool-result.js";
17
+ import {
18
+ executeTsqChange,
19
+ executeTsqMarkPlanned,
20
+ type TsqChangeAction,
21
+ } from "./tools-change.js";
22
+
23
+ // ── Action mapping ─────────────────────────────────────────────────
24
+
25
+ const BULK_TO_CHANGE_ACTION: Record<
26
+ Exclude<BulkItemAction, "mark_planned">,
27
+ TsqChangeAction
28
+ > = {
29
+ start: "start",
30
+ finish: "done",
31
+ reopen: "reopen",
32
+ defer: "defer",
33
+ note: "note",
34
+ };
35
+
36
+ // ── Public executor ────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Execute a validated bulk lifecycle operation.
40
+ *
41
+ * Items run sequentially via the existing mutation queue.
42
+ * On first failure, remaining items are marked skipped (no rollback).
43
+ * Result details are always `ok: true` — failure info lives in `BulkResult.failed`.
44
+ */
45
+ export async function executeBulk(
46
+ pi: ExtensionAPI,
47
+ items: readonly BulkItem[],
48
+ signal: AbortSignal | undefined,
49
+ ctx: Pick<ExtensionContext, "cwd">,
50
+ ): Promise<AgentToolResult<StandardToolDetails<BulkResult>>> {
51
+ const completed: string[] = [];
52
+ let failed: BulkResult["failed"];
53
+ const skipped: string[] = [];
54
+
55
+ for (let i = 0; i < items.length; i++) {
56
+ const item = items[i]!;
57
+ const result = await executeOneItem(pi, item, signal, ctx);
58
+
59
+ if (isResultOk(result)) {
60
+ completed.push(item.task);
61
+ } else {
62
+ failed = { task: item.task, error: extractErrorMessage(result) };
63
+ for (let j = i + 1; j < items.length; j++) {
64
+ skipped.push(items[j]!.task);
65
+ }
66
+ break;
67
+ }
68
+ }
69
+
70
+ const bulkResult: BulkResult = {
71
+ completed,
72
+ ...(failed !== undefined ? { failed } : {}),
73
+ skipped,
74
+ };
75
+
76
+ return textToolResult(
77
+ formatBulkText(bulkResult, items.length),
78
+ okToolDetails(bulkResult),
79
+ );
80
+ }
81
+
82
+ // ── Single-item dispatch ───────────────────────────────────────────
83
+
84
+ function executeOneItem(
85
+ pi: ExtensionAPI,
86
+ item: BulkItem,
87
+ signal: AbortSignal | undefined,
88
+ ctx: Pick<ExtensionContext, "cwd">,
89
+ ): Promise<AgentToolResult<unknown>> {
90
+ if (item.action === "mark_planned") {
91
+ return executeTsqMarkPlanned(pi, item.task, signal, ctx);
92
+ }
93
+
94
+ const action = BULK_TO_CHANGE_ACTION[item.action];
95
+ const params = buildChangeParams(action, item);
96
+ return executeTsqChange(pi, params, signal, ctx);
97
+ }
98
+
99
+ function buildChangeParams(
100
+ action: TsqChangeAction,
101
+ item: BulkItem,
102
+ ): { action: TsqChangeAction; id: string; note?: string } {
103
+ if (item.because !== undefined) {
104
+ return { action, id: item.task, note: item.because };
105
+ }
106
+ return { action, id: item.task };
107
+ }
108
+
109
+ // ── Result inspection ──────────────────────────────────────────────
110
+
111
+ function isResultOk(result: AgentToolResult<unknown>): boolean {
112
+ const d = result.details;
113
+ return isRecord(d) && d.ok === true;
114
+ }
115
+
116
+ function extractErrorMessage(result: AgentToolResult<unknown>): string {
117
+ const d = result.details;
118
+ if (isRecord(d) && isRecord(d.error)) {
119
+ const msg = d.error.message;
120
+ if (typeof msg === "string") return msg;
121
+ }
122
+ return "unknown error";
123
+ }
124
+
125
+ // ── Formatting ─────────────────────────────────────────────────────
126
+
127
+ function formatBulkText(result: BulkResult, total: number): string {
128
+ if (result.failed === undefined) {
129
+ return `Bulk: ${result.completed.length}/${total} completed`;
130
+ }
131
+
132
+ const parts = [`Bulk: ${result.completed.length}/${total} completed`];
133
+ parts.push("1 failed");
134
+ if (result.skipped.length > 0) {
135
+ parts.push(`${result.skipped.length} skipped`);
136
+ }
137
+ return `${parts.join(", ")}. Failed: ${result.failed.task} \u2014 ${result.failed.error}`;
138
+ }
139
+
140
+ // ── Utilities ──────────────────────────────────────────────────────
141
+
142
+ function isRecord(value: unknown): value is Record<string, unknown> {
143
+ return typeof value === "object" && value !== null && !Array.isArray(value);
144
+ }
@@ -1,17 +1,22 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import {
3
3
  defineTool,
4
+ type AgentToolResult,
4
5
  type ExtensionAPI,
5
6
  type ExtensionContext,
6
7
  } from "@earendil-works/pi-coding-agent";
7
8
  import { type Static, Type } from "typebox";
8
- import { runQueuedMutation } from "./mutation-queue.js";
9
- import { runTsqJson } from "./runner.js";
9
+ import {
10
+ CHANGE_TASKS_PROMPT_GUIDELINES,
11
+ CHANGE_TASKS_PROMPT_SNIPPET,
12
+ } from "../guidelines/internal-tools.js";
10
13
  import {
11
14
  errorToolDetails,
12
15
  okToolDetails,
13
16
  textToolResult,
14
17
  } from "../shared/tool-result.js";
18
+ import { runQueuedMutation } from "./mutation-queue.js";
19
+ import { runTsqJson } from "./runner.js";
15
20
 
16
21
  export const TSQ_CHANGE_TOOL_NAME = "tsq_change";
17
22
 
@@ -34,27 +39,29 @@ export type TsqChangeAction = (typeof TSQ_CHANGE_ACTIONS)[number];
34
39
  export const TsqChangeParamsSchema = Type.Object(
35
40
  {
36
41
  action: StringEnum(TSQ_CHANGE_ACTIONS, {
37
- description: "Durable Tasque mutation to run",
42
+ description: "Durable task mutation to run",
38
43
  }),
39
44
  title: Type.Optional(
40
45
  Type.String({ description: "Task title (required for create)" }),
41
46
  ),
42
47
  id: Type.Optional(
43
48
  Type.String({
44
- description: "Tasque task id for lifecycle/note/claim actions",
49
+ description: "Durable task id for lifecycle/note/claim actions",
45
50
  }),
46
51
  ),
47
52
  kind: Type.Optional(
48
- Type.String({ description: "Tasque task kind (required for create)" }),
53
+ Type.String({ description: "Durable task kind (required for create)" }),
49
54
  ),
50
55
  priority: Type.Optional(
51
- Type.Integer({ description: "Tasque priority (required for create)" }),
56
+ Type.Integer({
57
+ description: "Durable task priority (required for create)",
58
+ }),
52
59
  ),
53
60
  description: Type.Optional(
54
61
  Type.String({ description: "Task description (create only)" }),
55
62
  ),
56
63
  parent: Type.Optional(
57
- Type.String({ description: "Parent Tasque task id (create only)" }),
64
+ Type.String({ description: "Parent durable task id (create only)" }),
58
65
  ),
59
66
  planned: Type.Optional(
60
67
  Type.Boolean({ description: "Mark created task planned" }),
@@ -121,61 +128,110 @@ export function registerTsqChangeTool(pi: ExtensionAPI): void {
121
128
  pi.registerTool(
122
129
  defineTool({
123
130
  name: TSQ_CHANGE_TOOL_NAME,
124
- label: "Tasque Change",
131
+ label: "Task Change",
125
132
  description:
126
- "Run minimal durable Tasque mutations. Supports create, note, done, reopen, defer, start, assignment-only claim, and block/order edge changes. No raw tsq passthrough.",
127
- promptSnippet:
128
- "tsq_change: mutate durable Tasque tasks only through approved lifecycle/note/edge actions.",
129
- promptGuidelines: [
130
- "Use tsq_change only for explicit durable Tasque mutations; do not use it as a raw tsq passthrough.",
131
- "Use todo for current-session checklist steps; use tsq_change when durable Tasque state must change.",
132
- "Use block/unblock for hard blockers and order/unorder for sequencing where one task should happen after another.",
133
- "Use tsq_query with action deps or show to inspect durable graph state before or after edge changes.",
134
- ],
133
+ "Mutate durable tasks: lifecycle, notes, ownership, dependencies, and sequencing.",
134
+ promptSnippet: CHANGE_TASKS_PROMPT_SNIPPET,
135
+ promptGuidelines: CHANGE_TASKS_PROMPT_GUIDELINES,
135
136
  parameters: TsqChangeParamsSchema,
136
137
  executionMode: "sequential",
137
138
 
138
139
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
139
- const command = buildMutationCommand(
140
- params as Readonly<Record<string, unknown>>,
141
- );
142
- if (!command.ok) {
143
- return validationErrorResult(command.message);
144
- }
145
-
146
- try {
147
- const result = await runMutation(pi, ctx, command.argv, signal);
148
- return textToolResult(
149
- formatSuccess(command.action, params, result),
150
- okToolDetails({
151
- action: command.action,
152
- argv: command.argv,
153
- result,
154
- }),
155
- );
156
- } catch (error) {
157
- const message = getErrorMessage(error);
158
- return textToolResult(
159
- `Error: ${message}`,
160
- errorToolDetails({
161
- code: getErrorCode(error),
162
- message,
163
- details: {
164
- action: command.action,
165
- argv: command.argv,
166
- error: serializeError(error),
167
- },
168
- }),
169
- );
170
- }
140
+ return executeTsqChange(pi, params as TsqChangeParams, signal, ctx);
171
141
  },
172
142
  }),
173
143
  );
174
144
  }
175
145
 
146
+ export async function executeTsqChange(
147
+ pi: ExtensionAPI,
148
+ params: TsqChangeParams,
149
+ signal: AbortSignal | undefined,
150
+ ctx: Pick<ExtensionContext, "cwd">,
151
+ ): Promise<AgentToolResult<TsqChangeDetails>> {
152
+ const command = buildMutationCommand(
153
+ params as Readonly<Record<string, unknown>>,
154
+ );
155
+ if (!command.ok) {
156
+ return validationErrorResult(command.message);
157
+ }
158
+
159
+ try {
160
+ const result = await runMutation(pi, ctx, command.argv, signal);
161
+ return textToolResult(
162
+ formatSuccess(command.action, params, result),
163
+ okToolDetails({
164
+ action: command.action,
165
+ argv: command.argv,
166
+ result,
167
+ }),
168
+ );
169
+ } catch (error) {
170
+ const message = getErrorMessage(error);
171
+ return textToolResult(
172
+ `Error: ${message}`,
173
+ errorToolDetails({
174
+ code: getErrorCode(error),
175
+ message,
176
+ details: {
177
+ action: command.action,
178
+ argv: command.argv,
179
+ error: serializeError(error),
180
+ },
181
+ }),
182
+ );
183
+ }
184
+ }
185
+
186
+ // --- mark_planned helper (tsq-5.2) ---
187
+
188
+ export interface TsqMarkPlannedSuccessData {
189
+ readonly argv: readonly string[];
190
+ readonly result: unknown;
191
+ }
192
+
193
+ export type TsqMarkPlannedDetails = ReturnType<
194
+ typeof okToolDetails<TsqMarkPlannedSuccessData>
195
+ >;
196
+
197
+ export async function executeTsqMarkPlanned(
198
+ pi: ExtensionAPI,
199
+ taskId: string,
200
+ signal: AbortSignal | undefined,
201
+ ctx: Pick<ExtensionContext, "cwd">,
202
+ ): Promise<AgentToolResult<TsqMarkPlannedDetails>> {
203
+ const trimmed = taskId.trim();
204
+ if (trimmed.length === 0) {
205
+ return validationErrorResult("task id is required");
206
+ }
207
+
208
+ const argv = ["planned", trimmed];
209
+
210
+ try {
211
+ const result = await runMutation(pi, ctx, argv, signal);
212
+ return textToolResult(
213
+ `Marked ${trimmed} as planned`,
214
+ okToolDetails({ argv, result }),
215
+ );
216
+ } catch (error) {
217
+ const message = getErrorMessage(error);
218
+ return textToolResult(
219
+ `Error: ${message}`,
220
+ errorToolDetails({
221
+ code: getErrorCode(error),
222
+ message,
223
+ details: {
224
+ argv,
225
+ error: serializeError(error),
226
+ },
227
+ }),
228
+ );
229
+ }
230
+ }
231
+
176
232
  function runMutation(
177
233
  pi: ExtensionAPI,
178
- ctx: ExtensionContext,
234
+ ctx: Pick<ExtensionContext, "cwd">,
179
235
  argv: readonly string[],
180
236
  signal: AbortSignal | undefined,
181
237
  ): Promise<unknown> {
@@ -192,7 +248,7 @@ function buildMutationCommand(
192
248
  if (!isTsqChangeAction(action)) {
193
249
  return {
194
250
  ok: false,
195
- message: "action must be a supported tsq_change action",
251
+ message: "action must be a supported durable task mutation",
196
252
  };
197
253
  }
198
254
 
@@ -5,6 +5,10 @@ import {
5
5
  type ExtensionContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { type Static, Type } from "typebox";
8
+ import {
9
+ CLAIM_TASK_PROMPT_GUIDELINES,
10
+ CLAIM_TASK_PROMPT_SNIPPET,
11
+ } from "../guidelines/internal-tools.js";
8
12
  import {
9
13
  applyTaskMutation,
10
14
  type Op,
@@ -24,7 +28,7 @@ export const TSQ_CLAIM_TOOL_NAME = "tsq_claim";
24
28
 
25
29
  export const TsqClaimParamsSchema = Type.Object(
26
30
  {
27
- id: Type.String({ description: "Named Tasque task id to claim." }),
31
+ id: Type.String({ description: "Named durable task id to claim." }),
28
32
  assignee: Type.Optional(
29
33
  Type.String({
30
34
  description: "Agent or role claiming the task. Defaults to pi.",
@@ -38,8 +42,7 @@ export const TsqClaimParamsSchema = Type.Object(
38
42
  ),
39
43
  requireSpec: Type.Optional(
40
44
  Type.Boolean({
41
- description:
42
- "Require an attached Tasque spec before the claim succeeds.",
45
+ description: "Require an attached task spec before the claim succeeds.",
43
46
  }),
44
47
  ),
45
48
  createTodo: Type.Optional(
@@ -95,16 +98,10 @@ export function registerTsqClaimTool(pi: ExtensionAPI): void {
95
98
  pi.registerTool(
96
99
  defineTool({
97
100
  name: TSQ_CLAIM_TOOL_NAME,
98
- label: "Tasque Claim",
99
- description:
100
- "Claim a named durable Tasque task. Requires an explicit id; does not auto-select next ready work.",
101
- promptSnippet:
102
- "Use tsq_claim for named durable Tasque ownership. Provide id; assignee defaults to pi; start defaults to true.",
103
- promptGuidelines: [
104
- "Pass your own role/name as assignee when available, e.g. developer, worker, oracle.",
105
- "Use createTodo only when you want one session todo linked to the claimed durable task.",
106
- "Completing a linked session todo does not mark the Tasque task done; durable completion must be explicit.",
107
- ],
101
+ label: "Task Claim",
102
+ description: "Claim named durable task ownership.",
103
+ promptSnippet: CLAIM_TASK_PROMPT_SNIPPET,
104
+ promptGuidelines: CLAIM_TASK_PROMPT_GUIDELINES,
108
105
  parameters: TsqClaimParamsSchema,
109
106
  executionMode: "sequential",
110
107
 
@@ -5,6 +5,10 @@ import type {
5
5
  ExtensionContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { type Static, Type } from "typebox";
8
+ import {
9
+ READ_TASKS_PROMPT_GUIDELINES,
10
+ READ_TASKS_PROMPT_SNIPPET,
11
+ } from "../guidelines/internal-tools.js";
8
12
  import { truncatedTextToolResult } from "../shared/tool-result.js";
9
13
  import type { TruncatedText } from "../shared/truncation.js";
10
14
  import { runTsqJson } from "./runner.js";
@@ -40,12 +44,12 @@ export type TsqQueryAction = (typeof TSQ_QUERY_ACTIONS)[number];
40
44
  export const TsqQueryParamsSchema = Type.Object(
41
45
  {
42
46
  action: StringEnum(TSQ_QUERY_ACTIONS, {
43
- description: "Read-only Tasque query to run.",
47
+ description: "Read-only durable task query to run.",
44
48
  }),
45
49
  id: Type.Optional(
46
50
  Type.String({
47
51
  description:
48
- "Tasque task id. Required for show, show_with_spec, deps, and notes.",
52
+ "Durable task id. Required for show, spec details, deps, and notes.",
49
53
  }),
50
54
  ),
51
55
  lane: Type.Optional(
@@ -98,15 +102,10 @@ const DEFAULT_QUERY_TIMEOUT_MS = 10_000;
98
102
  export function registerTsqQueryTool(pi: ExtensionAPI): void {
99
103
  pi.registerTool({
100
104
  name: TSQ_QUERY_TOOL_NAME,
101
- label: "Tasque Query",
102
- description:
103
- "Run read-only Tasque durable-task queries through tsq --format json. Does not mutate Tasque state.",
104
- promptSnippet:
105
- "Use tsq_query for fresh read-only Tasque task state such as doctor, ready/open lists, show, deps, notes, tree, and similar lookups.",
106
- promptGuidelines: [
107
- "tsq_query is read-only; use mutation tools for lifecycle or notes.",
108
- "Use show_with_spec only when spec content is needed; regular show is more concise.",
109
- ],
105
+ label: "Task Query",
106
+ description: "Read durable task state. Does not mutate tasks.",
107
+ promptSnippet: READ_TASKS_PROMPT_SNIPPET,
108
+ promptGuidelines: READ_TASKS_PROMPT_GUIDELINES,
110
109
  parameters: TsqQueryParamsSchema,
111
110
  executionMode: "parallel",
112
111
  async execute(
@@ -132,10 +131,11 @@ export async function executeTsqQuery(
132
131
  AgentToolResult<TsqQueryDetails & { readonly truncation: TruncatedText }>
133
132
  > {
134
133
  const argv = buildTsqQueryArgv(params);
135
- const data = await runTsqJson<TsqQueryData>(pi, { cwd: ctx.cwd }, argv, {
134
+ const rawData = await runTsqJson<TsqQueryData>(pi, { cwd: ctx.cwd }, argv, {
136
135
  timeout: DEFAULT_QUERY_TIMEOUT_MS,
137
136
  ...(signal === undefined ? {} : { signal }),
138
137
  });
138
+ const data = normalizeQueryData(params, rawData);
139
139
  const text = formatTsqQueryResult(params, data);
140
140
 
141
141
  const details: TsqQueryDetails = {
@@ -197,6 +197,24 @@ export function buildTsqQueryArgv(params: TsqQueryParams): string[] {
197
197
  }
198
198
  }
199
199
 
200
+ function normalizeQueryData(
201
+ params: TsqQueryParams,
202
+ data: TsqQueryData,
203
+ ): TsqQueryData {
204
+ if (params.action !== "find_tree") {
205
+ return data;
206
+ }
207
+
208
+ const id = params.id?.trim();
209
+ if (id === undefined || id.length === 0) {
210
+ return data;
211
+ }
212
+
213
+ const found = findTaskTreeNode(getTreeRoots(data), id);
214
+ const filtered = found === undefined ? [] : [found];
215
+ return Array.isArray(data) ? filtered : { tree: filtered };
216
+ }
217
+
200
218
  function formatTsqQueryResult(
201
219
  params: TsqQueryParams,
202
220
  data: TsqQueryData,
@@ -229,7 +247,7 @@ function formatDoctor(data: TsqQueryData): string {
229
247
  ? doctor.issues.length
230
248
  : undefined;
231
249
  const parts = [
232
- "Tasque doctor",
250
+ "Task doctor",
233
251
  formatMetric(doctor.tasks, "tasks"),
234
252
  formatMetric(doctor.events, "events"),
235
253
  issueCount === undefined ? undefined : `${issueCount} issues`,
@@ -242,7 +260,7 @@ function formatTaskList(
242
260
  tasks: readonly TsqTask[],
243
261
  ): string {
244
262
  const lines = [
245
- `Tasque ${action}: ${formatCount(tasks.length, "task")}${formatLimitNotice(tasks.length)}`,
263
+ `Task ${action}: ${formatCount(tasks.length, "task")}${formatLimitNotice(tasks.length)}`,
246
264
  ...tasks.slice(0, MAX_RENDERED_ITEMS).map(formatTaskLine),
247
265
  ];
248
266
  return lines.join("\n");
@@ -268,21 +286,19 @@ function formatShow(data: TsqQueryData): string {
268
286
  ]
269
287
  .filter((part): part is string => part !== undefined)
270
288
  .join(" · ");
271
- return [`Tasque task: ${parts}: ${task.title}`, extra]
272
- .filter(Boolean)
273
- .join("\n");
289
+ return [`Task: ${parts}: ${task.title}`, extra].filter(Boolean).join("\n");
274
290
  }
275
- return "Tasque task: no task data returned";
291
+ return "Task: no task data returned";
276
292
  }
277
293
 
278
294
  function formatDeps(data: TsqQueryData): string {
279
295
  const root = (data as Partial<{ root: TsqDepTreeNode }>).root;
280
296
  if (root === undefined) {
281
- return "Tasque deps: no dependency data returned";
297
+ return "Task deps: no dependency data returned";
282
298
  }
283
299
  const lines = flattenDepTree(root).slice(0, MAX_RENDERED_ITEMS);
284
300
  return [
285
- `Tasque deps: ${root.id}${formatLimitNotice(countDepTree(root))}`,
301
+ `Task deps: ${root.id}${formatLimitNotice(countDepTree(root))}`,
286
302
  ...lines.map(
287
303
  ({ node, indent }) =>
288
304
  `${" ".repeat(indent)}${formatTaskLine(node.task)}`,
@@ -295,7 +311,7 @@ function formatNotes(data: TsqQueryData): string {
295
311
  const notes = notesData.notes ?? [];
296
312
  const taskId = notesData.task_id ?? "task";
297
313
  return [
298
- `Tasque notes for ${taskId}: ${formatCount(notes.length, "note")}${formatLimitNotice(notes.length)}`,
314
+ `Task notes for ${taskId}: ${formatCount(notes.length, "note")}${formatLimitNotice(notes.length)}`,
299
315
  ...notes.slice(0, MAX_RENDERED_ITEMS).map((note) => {
300
316
  const firstLine = note.text.split(/\r?\n/u)[0] ?? "";
301
317
  return `${note.ts} ${note.actor}: ${truncateInline(firstLine, 120)}`;
@@ -307,7 +323,7 @@ function formatTree(data: TsqQueryData, label = "tree"): string {
307
323
  const roots = getTreeRoots(data);
308
324
  const flattened = roots.flatMap((root) => flattenTaskTree(root));
309
325
  return [
310
- `Tasque ${label}: ${formatCount(flattened.length, "task")}${formatLimitNotice(flattened.length)}`,
326
+ `Task ${label}: ${formatCount(flattened.length, "task")}${formatLimitNotice(flattened.length)}`,
311
327
  ...flattened
312
328
  .slice(0, MAX_RENDERED_ITEMS)
313
329
  .map(
@@ -321,7 +337,7 @@ function formatSimilar(data: TsqQueryData): string {
321
337
  const similar = data as Partial<TsqSimilarData>;
322
338
  const candidates = similar.candidates ?? [];
323
339
  return [
324
- `Tasque similar: ${formatCount(candidates.length, "candidate")}${formatLimitNotice(candidates.length)}`,
340
+ `Task similar: ${formatCount(candidates.length, "candidate")}${formatLimitNotice(candidates.length)}`,
325
341
  ...candidates.slice(0, MAX_RENDERED_ITEMS).map(formatCandidateLine),
326
342
  ].join("\n");
327
343
  }
@@ -342,6 +358,22 @@ function getTreeRoots(data: TsqQueryData): readonly TsqTaskTreeNode[] {
342
358
  return Array.isArray(tree) ? tree.filter(isTaskTreeNode) : [];
343
359
  }
344
360
 
361
+ function findTaskTreeNode(
362
+ roots: readonly TsqTaskTreeNode[],
363
+ id: string,
364
+ ): TsqTaskTreeNode | undefined {
365
+ for (const root of roots) {
366
+ if (root.task.id === id) {
367
+ return root;
368
+ }
369
+ const child = findTaskTreeNode(root.children, id);
370
+ if (child !== undefined) {
371
+ return child;
372
+ }
373
+ }
374
+ return undefined;
375
+ }
376
+
345
377
  function hasTreeData(data: TsqQueryData): boolean {
346
378
  return Array.isArray(data)
347
379
  ? data.some(isTaskTreeNode)
@@ -423,7 +455,7 @@ function validateDepth(depth: unknown): number | undefined {
423
455
  return undefined;
424
456
  }
425
457
  if (typeof depth !== "number" || !Number.isInteger(depth) || depth < 1) {
426
- throw new Error("tsq_query depth must be an integer >= 1");
458
+ throw new Error("task dependency depth must be an integer >= 1");
427
459
  }
428
460
  return depth;
429
461
  }
@@ -435,7 +467,7 @@ function requireString(
435
467
  ): string {
436
468
  const trimmed = value?.trim();
437
469
  if (trimmed === undefined || trimmed.length === 0) {
438
- throw new Error(`tsq_query action ${action} requires ${field}`);
470
+ throw new Error(`task action ${action} requires ${field}`);
439
471
  }
440
472
  return trimmed;
441
473
  }