botholomew 0.9.5 → 0.9.8

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.
@@ -32,9 +32,16 @@ import { listSchedulesTool } from "./schedule/list.ts";
32
32
  // Search tools
33
33
  import { searchGrepTool } from "./search/grep.ts";
34
34
  import { searchSemanticTool } from "./search/semantic.ts";
35
+ // Skill tools
36
+ import { skillEditTool } from "./skill/edit.ts";
37
+ import { skillListTool } from "./skill/list.ts";
38
+ import { skillReadTool } from "./skill/read.ts";
39
+ import { skillSearchTool } from "./skill/search.ts";
40
+ import { skillWriteTool } from "./skill/write.ts";
35
41
  // Task tools
36
42
  import { completeTaskTool } from "./task/complete.ts";
37
43
  import { createTaskTool } from "./task/create.ts";
44
+ import { deleteTaskTool } from "./task/delete.ts";
38
45
  import { failTaskTool } from "./task/fail.ts";
39
46
  import { listTasksTool } from "./task/list.ts";
40
47
  import { updateTaskTool } from "./task/update.ts";
@@ -54,6 +61,7 @@ export function registerAllTools(): void {
54
61
  registerTool(waitTaskTool);
55
62
  registerTool(createTaskTool);
56
63
  registerTool(updateTaskTool);
64
+ registerTool(deleteTaskTool);
57
65
  registerTool(listTasksTool);
58
66
  registerTool(viewTaskTool);
59
67
 
@@ -88,6 +96,13 @@ export function registerAllTools(): void {
88
96
  registerTool(searchGrepTool);
89
97
  registerTool(searchSemanticTool);
90
98
 
99
+ // Skill
100
+ registerTool(skillListTool);
101
+ registerTool(skillReadTool);
102
+ registerTool(skillWriteTool);
103
+ registerTool(skillEditTool);
104
+ registerTool(skillSearchTool);
105
+
91
106
  // Thread
92
107
  registerTool(listThreadsTool);
93
108
  registerTool(viewThreadTool);
@@ -0,0 +1,114 @@
1
+ import { join } from "node:path";
2
+ import { z } from "zod";
3
+ import { getSkillsDir } from "../../constants.ts";
4
+ import { parseSkillFile } from "../../skills/parser.ts";
5
+ import type { ToolDefinition } from "../tool.ts";
6
+
7
+ const PatchSchema = z.object({
8
+ start_line: z.number().describe("1-based inclusive start line"),
9
+ end_line: z
10
+ .number()
11
+ .describe("1-based inclusive end line (0 to insert without replacing)"),
12
+ content: z
13
+ .string()
14
+ .describe("Replacement text (empty string to delete lines)"),
15
+ });
16
+
17
+ const inputSchema = z.object({
18
+ name: z.string().describe("Skill name (case-insensitive)"),
19
+ patches: z.array(PatchSchema).describe("Patches to apply"),
20
+ });
21
+
22
+ const outputSchema = z.object({
23
+ name: z.string(),
24
+ path: z.string().nullable(),
25
+ applied: z.number(),
26
+ content: z.string(),
27
+ is_error: z.boolean(),
28
+ error_type: z.string().optional(),
29
+ message: z.string().optional(),
30
+ next_action_hint: z.string().optional(),
31
+ });
32
+
33
+ function applyPatches(
34
+ raw: string,
35
+ patches: Array<{ start_line: number; end_line: number; content: string }>,
36
+ ): string {
37
+ const lines = raw.split("\n");
38
+ const sorted = [...patches].sort((a, b) => b.start_line - a.start_line);
39
+
40
+ for (const patch of sorted) {
41
+ if (patch.end_line === 0) {
42
+ const insertLines = patch.content === "" ? [] : patch.content.split("\n");
43
+ lines.splice(patch.start_line - 1, 0, ...insertLines);
44
+ } else {
45
+ const deleteCount = patch.end_line - patch.start_line + 1;
46
+ const insertLines = patch.content === "" ? [] : patch.content.split("\n");
47
+ lines.splice(patch.start_line - 1, deleteCount, ...insertLines);
48
+ }
49
+ }
50
+
51
+ return lines.join("\n");
52
+ }
53
+
54
+ export const skillEditTool = {
55
+ name: "skill_edit",
56
+ description:
57
+ "[[ bash equivalent command: patch ]] Apply git-style line-range patches to a skill file (user-defined slash command). Operates on the whole file (frontmatter + body). Patches whose result would not parse as a valid skill are rejected without writing. Use skill_read first to inspect current line numbers.",
58
+ group: "skill",
59
+ inputSchema,
60
+ outputSchema,
61
+ execute: async (input, ctx) => {
62
+ const normalized = input.name.toLowerCase();
63
+ const filePath = join(getSkillsDir(ctx.projectDir), `${normalized}.md`);
64
+
65
+ const file = Bun.file(filePath);
66
+ if (!(await file.exists())) {
67
+ return {
68
+ name: normalized,
69
+ path: null,
70
+ applied: 0,
71
+ content: "",
72
+ is_error: true,
73
+ error_type: "not_found",
74
+ message: `Skill not found: ${input.name}`,
75
+ next_action_hint:
76
+ "Use skill_list to see available skills, or skill_write to create one.",
77
+ };
78
+ }
79
+
80
+ const original = await file.text();
81
+ const updated = applyPatches(original, input.patches);
82
+
83
+ try {
84
+ const parsed = parseSkillFile(updated, filePath);
85
+ if (parsed.name !== normalized) {
86
+ throw new Error(
87
+ `frontmatter name '${parsed.name}' no longer matches filename '${normalized}'`,
88
+ );
89
+ }
90
+ } catch (err) {
91
+ return {
92
+ name: normalized,
93
+ path: filePath,
94
+ applied: 0,
95
+ content: original,
96
+ is_error: true,
97
+ error_type: "invalid_skill",
98
+ message: `Patched content failed validation: ${err instanceof Error ? err.message : String(err)}`,
99
+ next_action_hint:
100
+ "Check that frontmatter YAML stays valid and the file still has a name/description.",
101
+ };
102
+ }
103
+
104
+ await Bun.write(filePath, updated);
105
+
106
+ return {
107
+ name: normalized,
108
+ path: filePath,
109
+ applied: input.patches.length,
110
+ content: updated,
111
+ is_error: false,
112
+ };
113
+ },
114
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,63 @@
1
+ import { basename } from "node:path";
2
+ import { z } from "zod";
3
+ import { loadSkills } from "../../skills/loader.ts";
4
+ import type { ToolDefinition } from "../tool.ts";
5
+
6
+ const inputSchema = z.object({
7
+ limit: z
8
+ .number()
9
+ .optional()
10
+ .default(100)
11
+ .describe("Max number of skills to return (default 100)"),
12
+ offset: z
13
+ .number()
14
+ .optional()
15
+ .default(0)
16
+ .describe("Skip the first N skills (default 0)"),
17
+ });
18
+
19
+ const outputSchema = z.object({
20
+ skills: z.array(
21
+ z.object({
22
+ name: z.string(),
23
+ description: z.string(),
24
+ arguments: z.array(z.string()),
25
+ filename: z.string(),
26
+ path: z.string(),
27
+ }),
28
+ ),
29
+ total: z.number(),
30
+ is_error: z.boolean(),
31
+ });
32
+
33
+ export const skillListTool = {
34
+ name: "skill_list",
35
+ description:
36
+ "[[ bash equivalent command: ls ]] List skills (user-defined slash commands) loaded from .botholomew/skills/. Returns name, description, argument names, and file path for each.",
37
+ group: "skill",
38
+ inputSchema,
39
+ outputSchema,
40
+ execute: async (input, ctx) => {
41
+ const skills = await loadSkills(ctx.projectDir);
42
+ const sorted = [...skills.values()].sort((a, b) =>
43
+ a.name.localeCompare(b.name),
44
+ );
45
+
46
+ const total = sorted.length;
47
+ const offset = input.offset ?? 0;
48
+ const limit = input.limit ?? 100;
49
+ const page = sorted.slice(offset, offset + limit);
50
+
51
+ return {
52
+ skills: page.map((s) => ({
53
+ name: s.name,
54
+ description: s.description,
55
+ arguments: s.arguments.map((a) => a.name),
56
+ filename: basename(s.filePath),
57
+ path: s.filePath,
58
+ })),
59
+ total,
60
+ is_error: false,
61
+ };
62
+ },
63
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,72 @@
1
+ import { z } from "zod";
2
+ import { loadSkills } from "../../skills/loader.ts";
3
+ import type { ToolDefinition } from "../tool.ts";
4
+
5
+ const inputSchema = z.object({
6
+ name: z.string().describe("Skill name (case-insensitive)"),
7
+ });
8
+
9
+ const ArgSchema = z.object({
10
+ name: z.string(),
11
+ description: z.string(),
12
+ required: z.boolean(),
13
+ default: z.string().optional(),
14
+ });
15
+
16
+ const outputSchema = z.object({
17
+ name: z.string(),
18
+ path: z.string().nullable(),
19
+ raw: z.string().nullable(),
20
+ description: z.string(),
21
+ arguments: z.array(ArgSchema),
22
+ body: z.string(),
23
+ is_error: z.boolean(),
24
+ error_type: z.string().optional(),
25
+ message: z.string().optional(),
26
+ next_action_hint: z.string().optional(),
27
+ });
28
+
29
+ export const skillReadTool = {
30
+ name: "skill_read",
31
+ description:
32
+ "[[ bash equivalent command: cat ]] Read a skill file (user-defined slash command) by name. Returns the raw file contents plus parsed fields. Returns a not_found error with the list of available names when the skill doesn't exist.",
33
+ group: "skill",
34
+ inputSchema,
35
+ outputSchema,
36
+ execute: async (input, ctx) => {
37
+ const skills = await loadSkills(ctx.projectDir);
38
+ const skill = skills.get(input.name.toLowerCase());
39
+
40
+ if (!skill) {
41
+ const available = [...skills.keys()].sort();
42
+ const hint =
43
+ available.length > 0
44
+ ? `Available: ${available.join(", ")}. Use skill_list to browse.`
45
+ : "No skills exist yet. Use skill_write to create one.";
46
+ return {
47
+ name: input.name,
48
+ path: null,
49
+ raw: null,
50
+ description: "",
51
+ arguments: [],
52
+ body: "",
53
+ is_error: true,
54
+ error_type: "not_found",
55
+ message: `Skill not found: ${input.name}`,
56
+ next_action_hint: hint,
57
+ };
58
+ }
59
+
60
+ const raw = await Bun.file(skill.filePath).text();
61
+
62
+ return {
63
+ name: skill.name,
64
+ path: skill.filePath,
65
+ raw,
66
+ description: skill.description,
67
+ arguments: skill.arguments,
68
+ body: skill.body,
69
+ is_error: false,
70
+ };
71
+ },
72
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,165 @@
1
+ import { z } from "zod";
2
+ import { loadSkills } from "../../skills/loader.ts";
3
+ import type { SkillDefinition } from "../../skills/parser.ts";
4
+ import type { ToolDefinition } from "../tool.ts";
5
+
6
+ const inputSchema = z.object({
7
+ query: z
8
+ .string()
9
+ .describe(
10
+ "Search query (matched against name, description, body, and arg metadata)",
11
+ ),
12
+ top_k: z
13
+ .number()
14
+ .optional()
15
+ .default(10)
16
+ .describe("Maximum number of results to return (default 10)"),
17
+ });
18
+
19
+ const outputSchema = z.object({
20
+ results: z.array(
21
+ z.object({
22
+ name: z.string(),
23
+ description: z.string(),
24
+ score: z.number(),
25
+ match_fields: z.array(z.string()),
26
+ snippet: z.string(),
27
+ }),
28
+ ),
29
+ is_error: z.boolean(),
30
+ hint: z.string().optional(),
31
+ });
32
+
33
+ const SNIPPET_RADIUS = 60;
34
+
35
+ function countOccurrences(haystack: string, needle: string): number {
36
+ if (needle === "") return 0;
37
+ let count = 0;
38
+ let from = 0;
39
+ while (true) {
40
+ const idx = haystack.indexOf(needle, from);
41
+ if (idx === -1) break;
42
+ count++;
43
+ from = idx + needle.length;
44
+ }
45
+ return count;
46
+ }
47
+
48
+ function buildSnippet(body: string, term: string): string {
49
+ const lower = body.toLowerCase();
50
+ const idx = lower.indexOf(term);
51
+ if (idx === -1) return body.slice(0, SNIPPET_RADIUS * 2);
52
+
53
+ const start = Math.max(0, idx - SNIPPET_RADIUS);
54
+ const end = Math.min(body.length, idx + term.length + SNIPPET_RADIUS);
55
+ const prefix = start > 0 ? "…" : "";
56
+ const suffix = end < body.length ? "…" : "";
57
+ return prefix + body.slice(start, end) + suffix;
58
+ }
59
+
60
+ interface ScoreEntry {
61
+ skill: SkillDefinition;
62
+ score: number;
63
+ matchFields: Set<string>;
64
+ firstBodyTerm: string | null;
65
+ }
66
+
67
+ function scoreSkill(skill: SkillDefinition, terms: string[]): ScoreEntry {
68
+ const name = skill.name.toLowerCase();
69
+ const desc = skill.description.toLowerCase();
70
+ const body = skill.body.toLowerCase();
71
+ const matchFields = new Set<string>();
72
+ let score = 0;
73
+ let firstBodyTerm: string | null = null;
74
+
75
+ for (const term of terms) {
76
+ if (term === "") continue;
77
+ if (name.includes(term)) {
78
+ score += 10;
79
+ matchFields.add("name");
80
+ }
81
+ if (desc.includes(term)) {
82
+ score += 5;
83
+ matchFields.add("description");
84
+ }
85
+ for (const arg of skill.arguments) {
86
+ if (arg.name.toLowerCase().includes(term)) {
87
+ score += 3;
88
+ matchFields.add("argument_name");
89
+ }
90
+ if (arg.description.toLowerCase().includes(term)) {
91
+ score += 2;
92
+ matchFields.add("argument_description");
93
+ }
94
+ }
95
+ const bodyHits = Math.min(countOccurrences(body, term), 5);
96
+ if (bodyHits > 0) {
97
+ score += bodyHits;
98
+ matchFields.add("body");
99
+ if (firstBodyTerm === null) firstBodyTerm = term;
100
+ }
101
+ }
102
+
103
+ return { skill, score, matchFields, firstBodyTerm };
104
+ }
105
+
106
+ export const skillSearchTool = {
107
+ name: "skill_search",
108
+ description:
109
+ "Keyword search over skills (user-defined slash commands). Matches against name, description, body, and argument metadata. Returns top-K ranked matches. Use skill_read after finding a match.",
110
+ group: "skill",
111
+ inputSchema,
112
+ outputSchema,
113
+ execute: async (input, ctx) => {
114
+ const skills = await loadSkills(ctx.projectDir);
115
+ const terms = input.query
116
+ .toLowerCase()
117
+ .split(/\s+/)
118
+ .filter((t) => t.length > 0);
119
+
120
+ if (skills.size === 0) {
121
+ return {
122
+ results: [],
123
+ is_error: false,
124
+ hint: "No skills exist yet. Use skill_write to create one.",
125
+ };
126
+ }
127
+
128
+ if (terms.length === 0) {
129
+ return {
130
+ results: [],
131
+ is_error: false,
132
+ hint: "Empty query. Provide one or more keywords, or use skill_list to browse.",
133
+ };
134
+ }
135
+
136
+ const scored = [...skills.values()].map((s) => scoreSkill(s, terms));
137
+ const matched = scored
138
+ .filter((e) => e.score > 0)
139
+ .sort((a, b) => b.score - a.score)
140
+ .slice(0, input.top_k ?? 10);
141
+
142
+ if (matched.length === 0) {
143
+ return {
144
+ results: [],
145
+ is_error: false,
146
+ hint: "No matches. Try broader terms, or use skill_list to browse.",
147
+ };
148
+ }
149
+
150
+ const fallbackTerm = terms[0] ?? "";
151
+ return {
152
+ results: matched.map((e) => {
153
+ const snippetTerm = e.firstBodyTerm ?? fallbackTerm;
154
+ return {
155
+ name: e.skill.name,
156
+ description: e.skill.description,
157
+ score: e.score,
158
+ match_fields: [...e.matchFields].sort(),
159
+ snippet: buildSnippet(e.skill.body, snippetTerm),
160
+ };
161
+ }),
162
+ is_error: false,
163
+ };
164
+ },
165
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,180 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { getSkillsDir } from "../../constants.ts";
5
+ import { parseSkillFile } from "../../skills/parser.ts";
6
+ import {
7
+ buildSkillFileContent,
8
+ validateSkillName,
9
+ } from "../../skills/writer.ts";
10
+ import type { ToolDefinition } from "../tool.ts";
11
+
12
+ const ArgInputSchema = z.object({
13
+ name: z
14
+ .string()
15
+ .min(1)
16
+ .describe("Argument name (referenced as $1, $2, … in the body)"),
17
+ description: z.string().optional().describe("Argument description"),
18
+ required: z
19
+ .boolean()
20
+ .optional()
21
+ .default(false)
22
+ .describe("Whether the argument is required"),
23
+ default: z
24
+ .string()
25
+ .optional()
26
+ .describe("Default value when the argument is omitted"),
27
+ });
28
+
29
+ const inputSchema = z.object({
30
+ name: z
31
+ .string()
32
+ .describe(
33
+ "Skill name (slash-command identifier). Will be normalized to lowercase + [a-z0-9-]. Reserved: help, skills, clear, exit.",
34
+ ),
35
+ description: z
36
+ .string()
37
+ .describe("Short description shown in /skills and /help"),
38
+ body: z
39
+ .string()
40
+ .describe(
41
+ "Prompt-template body (markdown). Use $ARGUMENTS or $1..$9 for argument substitution.",
42
+ ),
43
+ arguments: z
44
+ .array(ArgInputSchema)
45
+ .optional()
46
+ .describe("Argument definitions (positional)"),
47
+ on_conflict: z
48
+ .enum(["error", "overwrite"])
49
+ .optional()
50
+ .default("error")
51
+ .describe(
52
+ "What to do if a skill with this name already exists. Defaults to 'error'.",
53
+ ),
54
+ });
55
+
56
+ const outputSchema = z.object({
57
+ name: z.string().nullable(),
58
+ path: z.string().nullable(),
59
+ ref: z.string().nullable(),
60
+ created: z.boolean(),
61
+ is_error: z.boolean(),
62
+ error_type: z.string().optional(),
63
+ message: z.string().optional(),
64
+ next_action_hint: z.string().optional(),
65
+ });
66
+
67
+ export const skillWriteTool = {
68
+ name: "skill_write",
69
+ description:
70
+ "[[ bash equivalent command: tee ]] Create or overwrite a skill file (user-defined slash command) at .botholomew/skills/<name>.md. Fails with path_conflict when the file exists unless on_conflict='overwrite'. Reserved names (help, skills, clear, exit) are rejected. The generated file is parsed to validate before being written.",
71
+ group: "skill",
72
+ inputSchema,
73
+ outputSchema,
74
+ execute: async (input, ctx) => {
75
+ const nameCheck = validateSkillName(input.name);
76
+ if (!nameCheck.ok) {
77
+ const errorType =
78
+ nameCheck.reason === "reserved" ? "reserved_name" : "invalid_name";
79
+ const message =
80
+ nameCheck.reason === "reserved"
81
+ ? `'${input.name}' is reserved by a built-in slash command (help, skills, clear, exit).`
82
+ : nameCheck.reason === "too_long"
83
+ ? `Skill name too long (max 64 chars after normalization).`
84
+ : `'${input.name}' is not a valid skill name. After normalization (lowercase, [a-z0-9-], trimmed hyphens) it is empty.`;
85
+ return {
86
+ name: null,
87
+ path: null,
88
+ ref: null,
89
+ created: false,
90
+ is_error: true,
91
+ error_type: errorType,
92
+ message,
93
+ next_action_hint:
94
+ "Pick a different name made of lowercase letters, digits, and hyphens.",
95
+ };
96
+ }
97
+
98
+ const normalized = nameCheck.normalized;
99
+ const body = input.body.trim();
100
+ if (body === "") {
101
+ return {
102
+ name: normalized,
103
+ path: null,
104
+ ref: null,
105
+ created: false,
106
+ is_error: true,
107
+ error_type: "empty_body",
108
+ message: "Skill body is empty.",
109
+ next_action_hint:
110
+ "Provide a non-empty prompt template using $ARGUMENTS or $1..$9.",
111
+ };
112
+ }
113
+
114
+ const args = (input.arguments ?? []).map((a) => ({
115
+ name: a.name,
116
+ description: a.description ?? "",
117
+ required: a.required ?? false,
118
+ default: a.default,
119
+ }));
120
+
121
+ const skillsDir = getSkillsDir(ctx.projectDir);
122
+ const filePath = join(skillsDir, `${normalized}.md`);
123
+
124
+ const raw = buildSkillFileContent({
125
+ name: normalized,
126
+ description: input.description,
127
+ arguments: args,
128
+ body,
129
+ });
130
+
131
+ try {
132
+ const parsed = parseSkillFile(raw, filePath);
133
+ if (parsed.name !== normalized) {
134
+ throw new Error(
135
+ `frontmatter name '${parsed.name}' does not match expected '${normalized}'`,
136
+ );
137
+ }
138
+ } catch (err) {
139
+ return {
140
+ name: normalized,
141
+ path: null,
142
+ ref: null,
143
+ created: false,
144
+ is_error: true,
145
+ error_type: "invalid_skill",
146
+ message: `Generated skill content failed validation: ${err instanceof Error ? err.message : String(err)}`,
147
+ next_action_hint:
148
+ "Check description and body for unusual characters that break YAML.",
149
+ };
150
+ }
151
+
152
+ const onConflict = input.on_conflict ?? "error";
153
+ const existed = await Bun.file(filePath).exists();
154
+
155
+ if (existed && onConflict === "error") {
156
+ return {
157
+ name: normalized,
158
+ path: filePath,
159
+ ref: `skill:${normalized}`,
160
+ created: false,
161
+ is_error: true,
162
+ error_type: "path_conflict",
163
+ message: `Skill '${normalized}' already exists at ${filePath}.`,
164
+ next_action_hint:
165
+ "Retry with on_conflict='overwrite' to replace, or use skill_edit for a partial change.",
166
+ };
167
+ }
168
+
169
+ await mkdir(skillsDir, { recursive: true });
170
+ await Bun.write(filePath, raw);
171
+
172
+ return {
173
+ name: normalized,
174
+ path: filePath,
175
+ ref: `skill:${normalized}`,
176
+ created: !existed,
177
+ is_error: false,
178
+ };
179
+ },
180
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,54 @@
1
+ import { z } from "zod";
2
+ import { deleteTask, getTask } from "../../db/tasks.ts";
3
+ import { logger } from "../../utils/logger.ts";
4
+ import type { ToolDefinition } from "../tool.ts";
5
+
6
+ const inputSchema = z.object({
7
+ id: z.string().describe("ID of the task to delete"),
8
+ });
9
+
10
+ const outputSchema = z.object({
11
+ deleted_id: z.string().nullable(),
12
+ message: z.string(),
13
+ is_error: z.boolean(),
14
+ });
15
+
16
+ export const deleteTaskTool = {
17
+ name: "delete_task",
18
+ description:
19
+ "[[ bash equivalent command: rm ]] Delete a task permanently. Refuses in_progress tasks; wait for the worker to finish or run `botholomew task reset <id>` first.",
20
+ group: "task",
21
+ inputSchema,
22
+ outputSchema,
23
+ execute: async (input, ctx) => {
24
+ const existing = await getTask(ctx.conn, input.id);
25
+ if (!existing) {
26
+ return {
27
+ deleted_id: null,
28
+ message: `Task ${input.id} not found`,
29
+ is_error: true,
30
+ };
31
+ }
32
+ if (existing.status === "in_progress") {
33
+ return {
34
+ deleted_id: null,
35
+ message: `Cannot delete task ${input.id}: it is currently in_progress (claimed by ${existing.claimed_by ?? "unknown"}). Wait for the worker to finish, or reset it first via \`botholomew task reset ${input.id}\`.`,
36
+ is_error: true,
37
+ };
38
+ }
39
+ const ok = await deleteTask(ctx.conn, input.id);
40
+ if (!ok) {
41
+ return {
42
+ deleted_id: null,
43
+ message: `Failed to delete task ${input.id}`,
44
+ is_error: true,
45
+ };
46
+ }
47
+ logger.info(`Deleted task: ${existing.name} (${existing.id})`);
48
+ return {
49
+ deleted_id: existing.id,
50
+ message: `Deleted task "${existing.name}" (${existing.id})`,
51
+ is_error: false,
52
+ };
53
+ },
54
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;