botholomew 0.15.4 → 0.15.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.
@@ -33,7 +33,9 @@ export const contextCopyTool = {
33
33
  execute: async (input, ctx) => {
34
34
  try {
35
35
  if (input.overwrite && (await fileExists(ctx.projectDir, input.dst))) {
36
- await deleteContextPath(ctx.projectDir, input.dst);
36
+ await deleteContextPath(ctx.projectDir, input.dst, {
37
+ holderId: ctx.workerId,
38
+ });
37
39
  }
38
40
  await copyContextPath(ctx.projectDir, input.src, input.dst);
39
41
  return { src: input.src, dst: input.dst, is_error: false };
@@ -39,6 +39,7 @@ export const contextDeleteTool = {
39
39
  try {
40
40
  const result = await deleteContextPath(ctx.projectDir, input.path, {
41
41
  recursive: input.recursive,
42
+ holderId: ctx.workerId,
42
43
  });
43
44
  return {
44
45
  deleted: result.removed,
@@ -2,24 +2,16 @@ import { z } from "zod";
2
2
  import {
3
3
  applyPatches,
4
4
  IsDirectoryError,
5
+ MtimeConflictError,
5
6
  NotFoundError,
6
7
  readContextFile,
7
8
  } from "../../context/store.ts";
9
+ import { LinePatchSchema } from "../../fs/patches.ts";
8
10
  import type { ToolDefinition } from "../tool.ts";
9
11
 
10
- const PatchSchema = z.object({
11
- start_line: z.number().describe("1-based inclusive start line"),
12
- end_line: z
13
- .number()
14
- .describe("1-based inclusive end line (0 to insert without replacing)"),
15
- content: z
16
- .string()
17
- .describe("Replacement text (empty string to delete lines)"),
18
- });
19
-
20
12
  const inputSchema = z.object({
21
13
  path: z.string().describe("Project-relative path under context/"),
22
- patches: z.array(PatchSchema).describe("Patches to apply"),
14
+ patches: z.array(LinePatchSchema).describe("Patches to apply"),
23
15
  });
24
16
 
25
17
  const outputSchema = z.object({
@@ -28,6 +20,7 @@ const outputSchema = z.object({
28
20
  is_error: z.boolean(),
29
21
  error_type: z.string().optional(),
30
22
  message: z.string().optional(),
23
+ next_action_hint: z.string().optional(),
31
24
  });
32
25
 
33
26
  export const contextEditTool = {
@@ -43,6 +36,7 @@ export const contextEditTool = {
43
36
  ctx.projectDir,
44
37
  input.path,
45
38
  input.patches,
39
+ { holderId: ctx.workerId },
46
40
  );
47
41
  const content = await readContextFile(ctx.projectDir, input.path);
48
42
  return { applied, content, is_error: false };
@@ -65,6 +59,17 @@ export const contextEditTool = {
65
59
  message: `context/${err.path} is a directory`,
66
60
  };
67
61
  }
62
+ if (err instanceof MtimeConflictError) {
63
+ return {
64
+ applied: 0,
65
+ content: "",
66
+ is_error: true,
67
+ error_type: "mtime_conflict",
68
+ message: `context/${input.path} was modified concurrently — another writer (or an external editor) changed it between read and write.`,
69
+ next_action_hint:
70
+ "Call context_read to fetch the current content, recompute your patches against the new line numbers, and retry.",
71
+ };
72
+ }
68
73
  throw err;
69
74
  }
70
75
  },
@@ -32,9 +32,14 @@ export const contextMoveTool = {
32
32
  execute: async (input, ctx) => {
33
33
  try {
34
34
  if (input.overwrite && (await fileExists(ctx.projectDir, input.dst))) {
35
- await deleteContextPath(ctx.projectDir, input.dst, { recursive: true });
35
+ await deleteContextPath(ctx.projectDir, input.dst, {
36
+ recursive: true,
37
+ holderId: ctx.workerId,
38
+ });
36
39
  }
37
- await moveContextPath(ctx.projectDir, input.src, input.dst);
40
+ await moveContextPath(ctx.projectDir, input.src, input.dst, {
41
+ holderId: ctx.workerId,
42
+ });
38
43
  return { src: input.src, dst: input.dst, is_error: false };
39
44
  } catch (err) {
40
45
  if (err instanceof NotFoundError) {
@@ -38,7 +38,7 @@ export const contextWriteTool = {
38
38
  ctx.projectDir,
39
39
  input.path,
40
40
  input.content,
41
- { onConflict: input.on_conflict ?? "error" },
41
+ { onConflict: input.on_conflict ?? "error", holderId: ctx.workerId },
42
42
  );
43
43
  return { path: entry.path, is_error: false };
44
44
  } catch (err) {
@@ -0,0 +1,148 @@
1
+ import { join } from "node:path";
2
+ import { z } from "zod";
3
+ import { getPromptsDir } from "../../constants.ts";
4
+ import {
5
+ atomicWriteIfUnchanged,
6
+ MtimeConflictError,
7
+ readWithMtime,
8
+ } from "../../fs/atomic.ts";
9
+ import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
10
+ import {
11
+ parseContextFile,
12
+ serializeContextFile,
13
+ } from "../../utils/frontmatter.ts";
14
+ import type { ToolDefinition } from "../tool.ts";
15
+
16
+ const inputSchema = z.object({
17
+ name: z
18
+ .string()
19
+ .describe(
20
+ "Prompt name without extension (e.g. 'beliefs', 'goals', 'capabilities'). Resolves to prompts/<name>.md.",
21
+ ),
22
+ patches: z.array(LinePatchSchema).describe("Patches to apply"),
23
+ });
24
+
25
+ const outputSchema = z.object({
26
+ name: z.string(),
27
+ path: z.string().nullable(),
28
+ applied: z.number(),
29
+ content: z.string(),
30
+ is_error: z.boolean(),
31
+ error_type: z.string().optional(),
32
+ message: z.string().optional(),
33
+ next_action_hint: z.string().optional(),
34
+ });
35
+
36
+ export const promptEditTool = {
37
+ name: "prompt_edit",
38
+ description:
39
+ "[[ bash equivalent command: patch ]] Apply git-style line-range patches to a prompt file under prompts/. Operates on the whole file (frontmatter + body). Files marked `agent-modification: false` (e.g. soul.md) are protected. Use prompt_read first to inspect current line numbers.",
40
+ group: "context",
41
+ inputSchema,
42
+ outputSchema,
43
+ execute: async (input, ctx) => {
44
+ if (input.name.includes("/") || input.name.includes("..")) {
45
+ return {
46
+ name: input.name,
47
+ path: null,
48
+ applied: 0,
49
+ content: "",
50
+ is_error: true,
51
+ error_type: "invalid_name",
52
+ message: `Invalid prompt name: ${input.name}`,
53
+ next_action_hint:
54
+ "Use a basename without slashes or dots (e.g. 'beliefs').",
55
+ };
56
+ }
57
+ const filePath = join(getPromptsDir(ctx.projectDir), `${input.name}.md`);
58
+ const file = await readWithMtime(filePath);
59
+ if (!file) {
60
+ return {
61
+ name: input.name,
62
+ path: null,
63
+ applied: 0,
64
+ content: "",
65
+ is_error: true,
66
+ error_type: "not_found",
67
+ message: `Prompt not found: prompts/${input.name}.md`,
68
+ };
69
+ }
70
+
71
+ const original = file.content;
72
+ const preParsed = parseContextFile(original);
73
+ if (!preParsed.meta["agent-modification"]) {
74
+ return {
75
+ name: input.name,
76
+ path: filePath,
77
+ applied: 0,
78
+ content: original,
79
+ is_error: true,
80
+ error_type: "agent_modification_disabled",
81
+ message: `Agent modification not allowed for prompts/${input.name}.md`,
82
+ };
83
+ }
84
+
85
+ const updated = applyLinePatches(original, input.patches);
86
+ let postParsed: { meta: Record<string, unknown>; content: string };
87
+ try {
88
+ postParsed = parseContextFile(updated);
89
+ } catch (err) {
90
+ return {
91
+ name: input.name,
92
+ path: filePath,
93
+ applied: 0,
94
+ content: original,
95
+ is_error: true,
96
+ error_type: "invalid_frontmatter",
97
+ message: `Patched content failed to parse: ${err instanceof Error ? err.message : String(err)}`,
98
+ next_action_hint:
99
+ "Check that the frontmatter delimiters and YAML stay valid.",
100
+ };
101
+ }
102
+ if (!postParsed.meta["agent-modification"]) {
103
+ return {
104
+ name: input.name,
105
+ path: filePath,
106
+ applied: 0,
107
+ content: original,
108
+ is_error: true,
109
+ error_type: "agent_modification_disabled",
110
+ message: `Patch would clear agent-modification on prompts/${input.name}.md`,
111
+ next_action_hint:
112
+ "Don't change the agent-modification frontmatter flag.",
113
+ };
114
+ }
115
+
116
+ const serialized = serializeContextFile(
117
+ postParsed.meta,
118
+ postParsed.content,
119
+ );
120
+
121
+ try {
122
+ await atomicWriteIfUnchanged(filePath, serialized, file.mtimeMs);
123
+ } catch (err) {
124
+ if (err instanceof MtimeConflictError) {
125
+ return {
126
+ name: input.name,
127
+ path: filePath,
128
+ applied: 0,
129
+ content: original,
130
+ is_error: true,
131
+ error_type: "mtime_conflict",
132
+ message: `Prompt was modified concurrently: ${err.message}`,
133
+ next_action_hint:
134
+ "Re-read with prompt_read and recompute your patch line numbers before retrying.",
135
+ };
136
+ }
137
+ throw err;
138
+ }
139
+
140
+ return {
141
+ name: input.name,
142
+ path: filePath,
143
+ applied: input.patches.length,
144
+ content: serialized,
145
+ is_error: false,
146
+ };
147
+ },
148
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,70 @@
1
+ import { join } from "node:path";
2
+ import { z } from "zod";
3
+ import { getPromptsDir } from "../../constants.ts";
4
+ import type { ToolDefinition } from "../tool.ts";
5
+
6
+ const inputSchema = z.object({
7
+ name: z
8
+ .string()
9
+ .describe(
10
+ "Prompt name without extension (e.g. 'beliefs', 'goals', 'capabilities'). Resolves to prompts/<name>.md.",
11
+ ),
12
+ });
13
+
14
+ const outputSchema = z.object({
15
+ name: z.string(),
16
+ path: z.string().nullable(),
17
+ content: z.string(),
18
+ agent_modification: z.boolean(),
19
+ is_error: z.boolean(),
20
+ error_type: z.string().optional(),
21
+ message: z.string().optional(),
22
+ });
23
+
24
+ export const promptReadTool = {
25
+ name: "prompt_read",
26
+ description:
27
+ "[[ bash equivalent command: cat ]] Read a prompt file under prompts/ (e.g. beliefs, goals, capabilities, soul). Returns the whole file (frontmatter + body) for use with prompt_edit.",
28
+ group: "context",
29
+ inputSchema,
30
+ outputSchema,
31
+ execute: async (input, ctx) => {
32
+ if (input.name.includes("/") || input.name.includes("..")) {
33
+ return {
34
+ name: input.name,
35
+ path: null,
36
+ content: "",
37
+ agent_modification: false,
38
+ is_error: true,
39
+ error_type: "invalid_name",
40
+ message: `Invalid prompt name: ${input.name}`,
41
+ };
42
+ }
43
+ const filePath = join(getPromptsDir(ctx.projectDir), `${input.name}.md`);
44
+ const file = Bun.file(filePath);
45
+ if (!(await file.exists())) {
46
+ return {
47
+ name: input.name,
48
+ path: null,
49
+ content: "",
50
+ agent_modification: false,
51
+ is_error: true,
52
+ error_type: "not_found",
53
+ message: `Prompt not found: prompts/${input.name}.md`,
54
+ };
55
+ }
56
+ const content = await file.text();
57
+ // Cheap header sniff so the agent knows whether prompt_edit will be
58
+ // accepted before it constructs patches.
59
+ const agent_modification = /agent-modification:\s*true/.test(
60
+ content.split("---", 3).slice(0, 3).join("---"),
61
+ );
62
+ return {
63
+ name: input.name,
64
+ path: filePath,
65
+ content,
66
+ agent_modification,
67
+ is_error: false,
68
+ };
69
+ },
70
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -3,8 +3,6 @@ import { capabilitiesRefreshTool } from "./capabilities/refresh.ts";
3
3
  // Context tools
4
4
  import { pipeToContextTool } from "./context/pipe.ts";
5
5
  import { readLargeResultTool } from "./context/read-large-result.ts";
6
- import { updateBeliefsTool } from "./context/update-beliefs.ts";
7
- import { updateGoalsTool } from "./context/update-goals.ts";
8
6
  // Context — directory operations
9
7
  import { contextCreateDirTool } from "./dir/create.ts";
10
8
  import { contextDirSizeTool } from "./dir/size.ts";
@@ -24,8 +22,12 @@ import { mcpExecTool } from "./mcp/exec.ts";
24
22
  import { mcpInfoTool } from "./mcp/info.ts";
25
23
  import { mcpListToolsTool } from "./mcp/list-tools.ts";
26
24
  import { mcpSearchTool } from "./mcp/search.ts";
25
+ // Prompt tools
26
+ import { promptEditTool } from "./prompt/edit.ts";
27
+ import { promptReadTool } from "./prompt/read.ts";
27
28
  // Schedule tools
28
29
  import { createScheduleTool } from "./schedule/create.ts";
30
+ import { scheduleEditTool } from "./schedule/edit.ts";
29
31
  import { listSchedulesTool } from "./schedule/list.ts";
30
32
  // Search tools
31
33
  import { searchTool } from "./search/index.ts";
@@ -40,6 +42,7 @@ import { skillWriteTool } from "./skill/write.ts";
40
42
  import { completeTaskTool } from "./task/complete.ts";
41
43
  import { createTaskTool } from "./task/create.ts";
42
44
  import { deleteTaskTool } from "./task/delete.ts";
45
+ import { taskEditTool } from "./task/edit.ts";
43
46
  import { failTaskTool } from "./task/fail.ts";
44
47
  import { listTasksTool } from "./task/list.ts";
45
48
  import { updateTaskTool } from "./task/update.ts";
@@ -62,6 +65,7 @@ export function registerAllTools(): void {
62
65
  registerTool(waitTaskTool);
63
66
  registerTool(createTaskTool);
64
67
  registerTool(updateTaskTool);
68
+ registerTool(taskEditTool);
65
69
  registerTool(deleteTaskTool);
66
70
  registerTool(listTasksTool);
67
71
  registerTool(viewTaskTool);
@@ -79,8 +83,8 @@ export function registerAllTools(): void {
79
83
  registerTool(contextInfoTool);
80
84
  registerTool(contextExistsTool);
81
85
  registerTool(contextCountLinesTool);
82
- registerTool(updateBeliefsTool);
83
- registerTool(updateGoalsTool);
86
+ registerTool(promptReadTool);
87
+ registerTool(promptEditTool);
84
88
  registerTool(readLargeResultTool);
85
89
  registerTool(pipeToContextTool);
86
90
 
@@ -89,6 +93,7 @@ export function registerAllTools(): void {
89
93
 
90
94
  // Schedule
91
95
  registerTool(createScheduleTool);
96
+ registerTool(scheduleEditTool);
92
97
  registerTool(listSchedulesTool);
93
98
 
94
99
  // Search
@@ -0,0 +1,126 @@
1
+ import { z } from "zod";
2
+ import {
3
+ atomicWriteIfUnchanged,
4
+ MtimeConflictError,
5
+ readWithMtime,
6
+ } from "../../fs/atomic.ts";
7
+ import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
8
+ import type { ScheduleFrontmatter } from "../../schedules/schema.ts";
9
+ import {
10
+ parseScheduleFile,
11
+ scheduleFilePath,
12
+ serializeSchedule,
13
+ } from "../../schedules/store.ts";
14
+ import type { ToolDefinition } from "../tool.ts";
15
+
16
+ const inputSchema = z.object({
17
+ id: z.string().describe("Schedule id"),
18
+ patches: z.array(LinePatchSchema).describe("Patches to apply"),
19
+ });
20
+
21
+ const outputSchema = z.object({
22
+ id: z.string(),
23
+ path: z.string().nullable(),
24
+ applied: z.number(),
25
+ content: z.string(),
26
+ is_error: z.boolean(),
27
+ error_type: z.string().optional(),
28
+ message: z.string().optional(),
29
+ next_action_hint: z.string().optional(),
30
+ });
31
+
32
+ export const scheduleEditTool = {
33
+ name: "schedule_edit",
34
+ description:
35
+ "[[ bash equivalent command: patch ]] Apply git-style line-range patches to a schedule file. Operates on the whole file (frontmatter + body). Patches whose result fails frontmatter validation are rejected without writing. Re-serializes to canonicalize YAML and bump updated_at. Use schedule_list to find ids.",
36
+ group: "schedule",
37
+ inputSchema,
38
+ outputSchema,
39
+ execute: async (input, ctx) => {
40
+ const filePath = scheduleFilePath(ctx.projectDir, input.id);
41
+ const file = await readWithMtime(filePath);
42
+ if (!file) {
43
+ return {
44
+ id: input.id,
45
+ path: null,
46
+ applied: 0,
47
+ content: "",
48
+ is_error: true,
49
+ error_type: "not_found",
50
+ message: `Schedule not found: ${input.id}`,
51
+ next_action_hint:
52
+ "Use schedule_list to see available schedules, or create_schedule to make one.",
53
+ };
54
+ }
55
+
56
+ const original = file.content;
57
+ const updated = applyLinePatches(original, input.patches);
58
+
59
+ const parsed = parseScheduleFile(updated, file.mtimeMs);
60
+ if (!parsed.ok) {
61
+ return {
62
+ id: input.id,
63
+ path: filePath,
64
+ applied: 0,
65
+ content: original,
66
+ is_error: true,
67
+ error_type: "invalid_schedule",
68
+ message: `Patched content failed validation: ${parsed.reason}`,
69
+ next_action_hint:
70
+ "Check that frontmatter YAML stays valid and required fields (id, name, frequency, enabled, created_at, updated_at) are preserved.",
71
+ };
72
+ }
73
+ if (parsed.schedule.id !== input.id) {
74
+ return {
75
+ id: input.id,
76
+ path: filePath,
77
+ applied: 0,
78
+ content: original,
79
+ is_error: true,
80
+ error_type: "id_mismatch",
81
+ message: `frontmatter id '${parsed.schedule.id}' does not match the schedule id '${input.id}'`,
82
+ next_action_hint:
83
+ "Don't change the id frontmatter field; create a new schedule with create_schedule if you need a different id.",
84
+ };
85
+ }
86
+
87
+ const fm: ScheduleFrontmatter = {
88
+ id: parsed.schedule.id,
89
+ name: parsed.schedule.name,
90
+ description: parsed.schedule.description,
91
+ frequency: parsed.schedule.frequency,
92
+ enabled: parsed.schedule.enabled,
93
+ last_run_at: parsed.schedule.last_run_at,
94
+ created_at: parsed.schedule.created_at,
95
+ updated_at: new Date().toISOString(),
96
+ };
97
+ const serialized = serializeSchedule(fm, parsed.schedule.body);
98
+
99
+ try {
100
+ await atomicWriteIfUnchanged(filePath, serialized, file.mtimeMs);
101
+ } catch (err) {
102
+ if (err instanceof MtimeConflictError) {
103
+ return {
104
+ id: input.id,
105
+ path: filePath,
106
+ applied: 0,
107
+ content: original,
108
+ is_error: true,
109
+ error_type: "mtime_conflict",
110
+ message: `Schedule was modified concurrently: ${err.message}`,
111
+ next_action_hint:
112
+ "Re-read the schedule with schedule_list and recompute your patch line numbers before retrying.",
113
+ };
114
+ }
115
+ throw err;
116
+ }
117
+
118
+ return {
119
+ id: input.id,
120
+ path: filePath,
121
+ applied: input.patches.length,
122
+ content: serialized,
123
+ is_error: false,
124
+ };
125
+ },
126
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,22 +1,13 @@
1
1
  import { join } from "node:path";
2
2
  import { z } from "zod";
3
3
  import { getSkillsDir } from "../../constants.ts";
4
+ import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
4
5
  import { parseSkillFile } from "../../skills/parser.ts";
5
6
  import type { ToolDefinition } from "../tool.ts";
6
7
 
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
8
  const inputSchema = z.object({
18
9
  name: z.string().describe("Skill name (case-insensitive)"),
19
- patches: z.array(PatchSchema).describe("Patches to apply"),
10
+ patches: z.array(LinePatchSchema).describe("Patches to apply"),
20
11
  });
21
12
 
22
13
  const outputSchema = z.object({
@@ -30,27 +21,6 @@ const outputSchema = z.object({
30
21
  next_action_hint: z.string().optional(),
31
22
  });
32
23
 
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
24
  export const skillEditTool = {
55
25
  name: "skill_edit",
56
26
  description:
@@ -78,7 +48,7 @@ export const skillEditTool = {
78
48
  }
79
49
 
80
50
  const original = await file.text();
81
- const updated = applyPatches(original, input.patches);
51
+ const updated = applyLinePatches(original, input.patches);
82
52
 
83
53
  try {
84
54
  const parsed = parseSkillFile(updated, filePath);