botholomew 0.12.3 → 0.13.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.
Files changed (104) hide show
  1. package/README.md +91 -68
  2. package/package.json +3 -3
  3. package/src/chat/agent.ts +42 -82
  4. package/src/chat/session.ts +29 -25
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +177 -926
  7. package/src/commands/db.ts +9 -13
  8. package/src/commands/init.ts +4 -1
  9. package/src/commands/nuke.ts +57 -90
  10. package/src/commands/schedule.ts +103 -124
  11. package/src/commands/skill.ts +2 -2
  12. package/src/commands/task.ts +86 -95
  13. package/src/commands/thread.ts +107 -112
  14. package/src/commands/worker.ts +88 -88
  15. package/src/constants.ts +93 -16
  16. package/src/context/capabilities.ts +10 -10
  17. package/src/context/fetcher.ts +9 -10
  18. package/src/context/reindex.ts +189 -0
  19. package/src/context/store.ts +630 -0
  20. package/src/db/doctor.ts +1 -8
  21. package/src/db/embeddings.ts +227 -175
  22. package/src/db/sql/19-disk_backed_index.sql +36 -0
  23. package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
  24. package/src/fs/atomic.ts +217 -0
  25. package/src/fs/compat.ts +86 -0
  26. package/src/fs/sandbox.ts +279 -0
  27. package/src/init/index.ts +69 -52
  28. package/src/init/templates.ts +1 -1
  29. package/src/mcpx/client.ts +1 -1
  30. package/src/schedules/schema.ts +19 -0
  31. package/src/schedules/store.ts +296 -0
  32. package/src/skills/commands.ts +1 -3
  33. package/src/tasks/schema.ts +47 -0
  34. package/src/tasks/store.ts +486 -0
  35. package/src/threads/store.ts +559 -0
  36. package/src/tools/capabilities/refresh.ts +42 -21
  37. package/src/tools/context/pipe.ts +15 -71
  38. package/src/tools/context/update-beliefs.ts +3 -3
  39. package/src/tools/context/update-goals.ts +3 -3
  40. package/src/tools/dir/create.ts +26 -23
  41. package/src/tools/dir/size.ts +46 -17
  42. package/src/tools/dir/tree.ts +73 -279
  43. package/src/tools/file/copy.ts +50 -24
  44. package/src/tools/file/count-lines.ts +34 -10
  45. package/src/tools/file/delete.ts +44 -23
  46. package/src/tools/file/edit.ts +39 -14
  47. package/src/tools/file/exists.ts +12 -26
  48. package/src/tools/file/info.ts +25 -85
  49. package/src/tools/file/move.ts +39 -24
  50. package/src/tools/file/read.ts +32 -80
  51. package/src/tools/file/write.ts +14 -91
  52. package/src/tools/registry.ts +3 -7
  53. package/src/tools/schedule/create.ts +2 -2
  54. package/src/tools/schedule/list.ts +7 -3
  55. package/src/tools/search/fuse.ts +12 -33
  56. package/src/tools/search/index.ts +36 -43
  57. package/src/tools/search/regexp.ts +29 -17
  58. package/src/tools/search/semantic.ts +137 -51
  59. package/src/tools/skill/delete.ts +1 -1
  60. package/src/tools/skill/list.ts +1 -1
  61. package/src/tools/skill/write.ts +1 -1
  62. package/src/tools/task/create.ts +41 -16
  63. package/src/tools/task/delete.ts +3 -3
  64. package/src/tools/task/list.ts +6 -3
  65. package/src/tools/task/update.ts +31 -9
  66. package/src/tools/task/view.ts +6 -6
  67. package/src/tools/thread/list.ts +2 -2
  68. package/src/tools/thread/search.ts +208 -0
  69. package/src/tools/thread/view.ts +50 -5
  70. package/src/tools/worker/spawn.ts +28 -14
  71. package/src/tui/App.tsx +12 -19
  72. package/src/tui/components/ContextPanel.tsx +83 -316
  73. package/src/tui/components/SchedulePanel.tsx +34 -48
  74. package/src/tui/components/StatusBar.tsx +15 -15
  75. package/src/tui/components/TaskPanel.tsx +34 -38
  76. package/src/tui/components/ThreadPanel.tsx +29 -38
  77. package/src/tui/components/WorkerPanel.tsx +21 -19
  78. package/src/tui/markdown.ts +2 -8
  79. package/src/types/file-imports.d.ts +9 -0
  80. package/src/utils/title.ts +5 -7
  81. package/src/utils/v7-date.ts +47 -0
  82. package/src/worker/heartbeat.ts +46 -24
  83. package/src/worker/index.ts +13 -15
  84. package/src/worker/llm.ts +30 -37
  85. package/src/worker/prompt.ts +19 -41
  86. package/src/worker/schedules.ts +48 -69
  87. package/src/worker/spawn.ts +11 -11
  88. package/src/worker/tick.ts +39 -43
  89. package/src/workers/store.ts +247 -0
  90. package/src/commands/tools.ts +0 -367
  91. package/src/context/describer.ts +0 -140
  92. package/src/context/drives.ts +0 -110
  93. package/src/context/ingest.ts +0 -162
  94. package/src/context/refresh.ts +0 -183
  95. package/src/db/context.ts +0 -637
  96. package/src/db/daemon-state.ts +0 -6
  97. package/src/db/reembed.ts +0 -113
  98. package/src/db/schedules.ts +0 -213
  99. package/src/db/tasks.ts +0 -347
  100. package/src/db/threads.ts +0 -276
  101. package/src/db/workers.ts +0 -212
  102. package/src/tools/context/list-drives.ts +0 -36
  103. package/src/tools/context/refresh.ts +0 -165
  104. package/src/tools/context/search.ts +0 -54
package/src/init/index.ts CHANGED
@@ -2,14 +2,30 @@ import { mkdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { loadConfig } from "../config/loader.ts";
4
4
  import {
5
- getBotholomewDir,
5
+ CONFIG_DIR,
6
+ CONFIG_FILENAME,
7
+ CONTEXT_DIR,
8
+ getConfigPath,
6
9
  getDbPath,
7
10
  getMcpxDir,
11
+ getPromptsDir,
12
+ getSchedulesDir,
13
+ getSchedulesLockDir,
8
14
  getSkillsDir,
15
+ getTasksDir,
16
+ getTasksLockDir,
17
+ getThreadsDir,
18
+ getWorkersDir,
19
+ LOCKS_SUBDIR,
20
+ LOGS_DIR,
21
+ MCPX_SERVERS_FILENAME,
22
+ SCHEDULES_DIR,
23
+ TASKS_DIR,
9
24
  } from "../constants.ts";
10
25
  import { writeCapabilitiesFile } from "../context/capabilities.ts";
11
26
  import { getConnection } from "../db/connection.ts";
12
27
  import { migrate } from "../db/schema.ts";
28
+ import { assertCompatibleFilesystem } from "../fs/compat.ts";
13
29
  import { createMcpxClient } from "../mcpx/client.ts";
14
30
  import { registerAllTools } from "../tools/registry.ts";
15
31
  import { logger } from "../utils/logger.ts";
@@ -29,56 +45,62 @@ export async function initProject(
29
45
  projectDir: string,
30
46
  opts: { force?: boolean } = {},
31
47
  ): Promise<void> {
32
- const dotDir = getBotholomewDir(projectDir);
33
- const mcpxDir = getMcpxDir(projectDir);
34
- const skillsDir = getSkillsDir(projectDir);
48
+ // Refuse to operate inside iCloud/Dropbox/etc unless --force is passed.
49
+ // Sync overlays break atomic rename / O_EXCL semantics that tasks and
50
+ // schedules depend on.
51
+ assertCompatibleFilesystem(projectDir, !!opts.force);
35
52
 
36
- // Check if already initialized
37
- const dirExists = await Bun.file(join(dotDir, "soul.md")).exists();
38
- if (dirExists && !opts.force) {
53
+ const configPath = getConfigPath(projectDir);
54
+ const alreadyInitialized = await Bun.file(configPath).exists();
55
+ if (alreadyInitialized && !opts.force) {
39
56
  throw new Error(
40
- `.botholomew already initialized in ${projectDir}. Use --force to reinitialize.`,
57
+ `Botholomew project already initialized in ${projectDir} (found ${CONFIG_DIR}/${CONFIG_FILENAME}). Use --force to reinitialize.`,
41
58
  );
42
59
  }
43
60
 
44
- // Create directories
45
- await mkdir(dotDir, { recursive: true });
46
- await mkdir(mcpxDir, { recursive: true });
47
- await mkdir(skillsDir, { recursive: true });
61
+ // Top-level directories
62
+ await mkdir(join(projectDir, CONFIG_DIR), { recursive: true });
63
+ await mkdir(getPromptsDir(projectDir), { recursive: true });
64
+ await mkdir(getSkillsDir(projectDir), { recursive: true });
65
+ await mkdir(getMcpxDir(projectDir), { recursive: true });
66
+ await mkdir(join(projectDir, CONTEXT_DIR), { recursive: true });
67
+ await mkdir(getTasksDir(projectDir), { recursive: true });
68
+ await mkdir(getTasksLockDir(projectDir), { recursive: true });
69
+ await mkdir(getSchedulesDir(projectDir), { recursive: true });
70
+ await mkdir(getSchedulesLockDir(projectDir), { recursive: true });
71
+ await mkdir(getWorkersDir(projectDir), { recursive: true });
72
+ await mkdir(getThreadsDir(projectDir), { recursive: true });
73
+ await mkdir(join(projectDir, LOGS_DIR), { recursive: true });
48
74
 
49
- // Write template files
50
- await Bun.write(join(dotDir, "soul.md"), SOUL_MD);
51
- await Bun.write(join(dotDir, "beliefs.md"), BELIEFS_MD);
52
- await Bun.write(join(dotDir, "goals.md"), GOALS_MD);
53
- await Bun.write(join(dotDir, "capabilities.md"), CAPABILITIES_MD);
75
+ // Persistent-context template files
76
+ const pcDir = getPromptsDir(projectDir);
77
+ await Bun.write(join(pcDir, "soul.md"), SOUL_MD);
78
+ await Bun.write(join(pcDir, "beliefs.md"), BELIEFS_MD);
79
+ await Bun.write(join(pcDir, "goals.md"), GOALS_MD);
80
+ await Bun.write(join(pcDir, "capabilities.md"), CAPABILITIES_MD);
54
81
 
55
- // Write default skills
82
+ // Default skills
83
+ const skillsDir = getSkillsDir(projectDir);
56
84
  await Bun.write(join(skillsDir, "summarize.md"), SUMMARIZE_SKILL);
57
85
  await Bun.write(join(skillsDir, "standup.md"), STANDUP_SKILL);
58
86
  await Bun.write(join(skillsDir, "capabilities.md"), CAPABILITIES_SKILL);
59
87
 
60
- // Write config (with placeholder API key)
61
- await Bun.write(
62
- join(dotDir, "config.json"),
63
- `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`,
64
- );
88
+ // Config
89
+ await Bun.write(configPath, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
65
90
 
66
- // Write mcpx servers config
91
+ // mcpx servers config
67
92
  await Bun.write(
68
- join(mcpxDir, "servers.json"),
93
+ join(getMcpxDir(projectDir), MCPX_SERVERS_FILENAME),
69
94
  `${JSON.stringify(DEFAULT_MCPX_SERVERS, null, 2)}\n`,
70
95
  );
71
96
 
72
- // Initialize database
97
+ // Initialize the index database (search index sidecar; rebuildable).
73
98
  const dbPath = getDbPath(projectDir);
74
99
  const conn = await getConnection(dbPath);
75
100
  await migrate(conn);
76
101
  conn.close();
77
102
 
78
- // Populate capabilities.md with the real tool inventory. Seeded mcpx
79
- // servers.json has no entries on first init, so this lists only the
80
- // built-in tools; running `botholomew capabilities` later after
81
- // adding MCPX servers picks those up.
103
+ // Populate capabilities.md with the real tool inventory.
82
104
  registerAllTools();
83
105
  const config = await loadConfig(projectDir);
84
106
  const mcpxClient = await createMcpxClient(projectDir);
@@ -88,33 +110,28 @@ export async function initProject(
88
110
  await mcpxClient?.close();
89
111
  }
90
112
 
91
- // Update .gitignore
92
- await updateGitignore(projectDir);
93
-
94
113
  logger.success("Initialized Botholomew project");
95
- logger.dim(` Directory: ${dotDir}`);
96
- logger.dim(` Database: ${dbPath}`);
114
+ logger.dim(` Project root: ${projectDir}`);
115
+ logger.dim(` Config: ${CONFIG_DIR}/${CONFIG_FILENAME}`);
116
+ logger.dim(` Index DB: ${dbPath}`);
117
+ logger.dim("");
118
+ logger.dim("Layout:");
119
+ logger.dim(` ${CONFIG_DIR}/ settings`);
120
+ logger.dim(` prompts/ soul, beliefs, goals, capabilities`);
121
+ logger.dim(` ${CONTEXT_DIR}/ agent-writable knowledge tree`);
122
+ logger.dim(` ${TASKS_DIR}/ one markdown file per task`);
123
+ logger.dim(` ${LOCKS_SUBDIR}/ worker claim lockfiles`);
124
+ logger.dim(` ${SCHEDULES_DIR}/ one markdown file per schedule`);
125
+ logger.dim(` threads/ one CSV per conversation, by UTC date`);
126
+ logger.dim(` workers/ one JSON pidfile per worker (heartbeats)`);
127
+ logger.dim(` skills/, mcpx/, models/, logs/`);
97
128
  logger.dim("");
98
129
  logger.dim("Next steps:");
99
- logger.dim(" 1. Set ANTHROPIC_API_KEY or add it to .botholomew/config.json");
130
+ logger.dim(
131
+ ` 1. Set ANTHROPIC_API_KEY or add it to ${CONFIG_DIR}/${CONFIG_FILENAME}`,
132
+ );
100
133
  logger.dim(" 2. Run 'botholomew task add' to create your first task");
101
134
  logger.dim(
102
135
  " 3. Run 'botholomew worker start --persist' to start a background worker",
103
136
  );
104
137
  }
105
-
106
- async function updateGitignore(projectDir: string): Promise<void> {
107
- const gitignorePath = join(projectDir, ".gitignore");
108
- const file = Bun.file(gitignorePath);
109
-
110
- let content = "";
111
- if (await file.exists()) {
112
- content = await file.text();
113
- }
114
-
115
- const entry = ".botholomew/";
116
- if (content.includes(entry)) return;
117
-
118
- const section = `\n# Botholomew (auto-generated)\n${entry}\n`;
119
- await Bun.write(gitignorePath, `${content.trimEnd()}\n${section}`);
120
- }
@@ -60,7 +60,7 @@ description: "Refresh capabilities.md — rescan internal and MCPX tools"
60
60
  arguments: []
61
61
  ---
62
62
 
63
- Call \`capabilities_refresh\` to rescan every available tool (built-in and MCPX) and rewrite \`.botholomew/capabilities.md\`. After it finishes, give me a one-line summary of the counts.
63
+ Call \`capabilities_refresh\` to rescan every available tool (built-in and MCPX) and rewrite \`prompts/capabilities.md\`. After it finishes, give me a one-line summary of the counts.
64
64
  `;
65
65
 
66
66
  export const SUMMARIZE_SKILL = `---
@@ -4,7 +4,7 @@ import { type CallToolResult, McpxClient } from "@evantahler/mcpx";
4
4
  import { getMcpxDir, MCPX_SERVERS_FILENAME } from "../constants.ts";
5
5
 
6
6
  /**
7
- * Create an McpxClient from the project's .botholomew/mcpx/servers.json.
7
+ * Create an McpxClient from the project's mcpx/servers.json.
8
8
  * Returns null if the file is missing or has no servers configured.
9
9
  */
10
10
  export async function createMcpxClient(
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+
3
+ export const ScheduleFrontmatterSchema = z.object({
4
+ id: z.string().min(1),
5
+ name: z.string(),
6
+ description: z.string().default(""),
7
+ frequency: z.string(),
8
+ enabled: z.boolean().default(true),
9
+ last_run_at: z.string().nullable().default(null),
10
+ created_at: z.string(),
11
+ updated_at: z.string(),
12
+ });
13
+
14
+ export type ScheduleFrontmatter = z.infer<typeof ScheduleFrontmatterSchema>;
15
+
16
+ export interface Schedule extends ScheduleFrontmatter {
17
+ mtimeMs: number;
18
+ body: string;
19
+ }
@@ -0,0 +1,296 @@
1
+ import { readdir, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import matter from "gray-matter";
4
+ import { getSchedulesDir, getSchedulesLockDir } from "../constants.ts";
5
+ import { uuidv7 } from "../db/uuid.ts";
6
+ import {
7
+ acquireLock,
8
+ atomicWrite,
9
+ atomicWriteIfUnchanged,
10
+ LockHeldError,
11
+ readLockHolder,
12
+ readWithMtime,
13
+ releaseLock,
14
+ } from "../fs/atomic.ts";
15
+ import { logger } from "../utils/logger.ts";
16
+ import {
17
+ type Schedule,
18
+ type ScheduleFrontmatter,
19
+ ScheduleFrontmatterSchema,
20
+ } from "./schema.ts";
21
+
22
+ function scheduleFilePath(projectDir: string, id: string): string {
23
+ return join(getSchedulesDir(projectDir), `${id}.md`);
24
+ }
25
+
26
+ function scheduleLockPath(projectDir: string, id: string): string {
27
+ return join(getSchedulesLockDir(projectDir), `${id}.lock`);
28
+ }
29
+
30
+ function serializeSchedule(fm: ScheduleFrontmatter, body: string): string {
31
+ return matter.stringify(`\n${body.trim()}\n`, fm as Record<string, unknown>);
32
+ }
33
+
34
+ interface ParseOk {
35
+ ok: true;
36
+ schedule: Schedule;
37
+ }
38
+ interface ParseFail {
39
+ ok: false;
40
+ reason: string;
41
+ }
42
+
43
+ function parseScheduleFile(raw: string, mtimeMs: number): ParseOk | ParseFail {
44
+ let parsed: matter.GrayMatterFile<string>;
45
+ try {
46
+ parsed = matter(raw);
47
+ } catch (err) {
48
+ return { ok: false, reason: `frontmatter parse error: ${err}` };
49
+ }
50
+ const result = ScheduleFrontmatterSchema.safeParse(parsed.data);
51
+ if (!result.success) {
52
+ return {
53
+ ok: false,
54
+ reason: `frontmatter validation failed: ${result.error.message}`,
55
+ };
56
+ }
57
+ return {
58
+ ok: true,
59
+ schedule: {
60
+ ...result.data,
61
+ mtimeMs,
62
+ body: parsed.content.trim(),
63
+ },
64
+ };
65
+ }
66
+
67
+ export async function listScheduleFiles(projectDir: string): Promise<string[]> {
68
+ const dir = getSchedulesDir(projectDir);
69
+ try {
70
+ const names = await readdir(dir);
71
+ return names.filter((n) => n.endsWith(".md")).map((n) => n.slice(0, -3));
72
+ } catch (err) {
73
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
74
+ throw err;
75
+ }
76
+ }
77
+
78
+ export async function getSchedule(
79
+ projectDir: string,
80
+ id: string,
81
+ ): Promise<Schedule | null> {
82
+ const file = await readWithMtime(scheduleFilePath(projectDir, id));
83
+ if (!file) return null;
84
+ const parsed = parseScheduleFile(file.content, file.mtimeMs);
85
+ if (!parsed.ok) {
86
+ logger.warn(`Schedule ${id} is malformed: ${parsed.reason}`);
87
+ return null;
88
+ }
89
+ return parsed.schedule;
90
+ }
91
+
92
+ export async function listSchedules(
93
+ projectDir: string,
94
+ filters?: { enabled?: boolean; limit?: number; offset?: number },
95
+ ): Promise<Schedule[]> {
96
+ const ids = await listScheduleFiles(projectDir);
97
+ const out: Schedule[] = [];
98
+ for (const id of ids) {
99
+ const s = await getSchedule(projectDir, id);
100
+ if (!s) continue;
101
+ if (filters?.enabled !== undefined && s.enabled !== filters.enabled)
102
+ continue;
103
+ out.push(s);
104
+ }
105
+ out.sort((a, b) => (a.created_at < b.created_at ? -1 : 1));
106
+ const offset = filters?.offset ?? 0;
107
+ const limit = filters?.limit ?? out.length;
108
+ return out.slice(offset, offset + limit);
109
+ }
110
+
111
+ export async function createSchedule(
112
+ projectDir: string,
113
+ params: {
114
+ name: string;
115
+ description?: string;
116
+ frequency: string;
117
+ enabled?: boolean;
118
+ },
119
+ ): Promise<Schedule> {
120
+ const id = uuidv7();
121
+ const now = new Date().toISOString();
122
+ const fm: ScheduleFrontmatter = {
123
+ id,
124
+ name: params.name,
125
+ description: params.description ?? "",
126
+ frequency: params.frequency,
127
+ enabled: params.enabled ?? true,
128
+ last_run_at: null,
129
+ created_at: now,
130
+ updated_at: now,
131
+ };
132
+ await atomicWrite(
133
+ scheduleFilePath(projectDir, id),
134
+ serializeSchedule(fm, params.description ?? ""),
135
+ );
136
+ const fresh = await getSchedule(projectDir, id);
137
+ if (!fresh) throw new Error(`Failed to read freshly created schedule ${id}`);
138
+ return fresh;
139
+ }
140
+
141
+ export async function updateSchedule(
142
+ projectDir: string,
143
+ id: string,
144
+ updates: Partial<
145
+ Pick<ScheduleFrontmatter, "name" | "description" | "frequency" | "enabled">
146
+ >,
147
+ ): Promise<Schedule | null> {
148
+ const s = await getSchedule(projectDir, id);
149
+ if (!s) return null;
150
+ // Drop undefined keys so an omitted field doesn't overwrite the on-disk
151
+ // value with `undefined` (YAML can't serialize undefined).
152
+ const definedUpdates = Object.fromEntries(
153
+ Object.entries(updates).filter(([, v]) => v !== undefined),
154
+ );
155
+ const fm: ScheduleFrontmatter = {
156
+ ...s,
157
+ ...definedUpdates,
158
+ updated_at: new Date().toISOString(),
159
+ };
160
+ await atomicWriteIfUnchanged(
161
+ scheduleFilePath(projectDir, id),
162
+ serializeSchedule(fm, s.body),
163
+ s.mtimeMs,
164
+ );
165
+ return getSchedule(projectDir, id);
166
+ }
167
+
168
+ export async function deleteSchedule(
169
+ projectDir: string,
170
+ id: string,
171
+ ): Promise<boolean> {
172
+ try {
173
+ await unlink(scheduleFilePath(projectDir, id));
174
+ } catch (err) {
175
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
176
+ throw err;
177
+ }
178
+ await releaseLock(scheduleLockPath(projectDir, id));
179
+ return true;
180
+ }
181
+
182
+ export async function deleteAllSchedules(projectDir: string): Promise<number> {
183
+ const ids = await listScheduleFiles(projectDir);
184
+ let n = 0;
185
+ for (const id of ids) {
186
+ if (await deleteSchedule(projectDir, id)) n++;
187
+ }
188
+ return n;
189
+ }
190
+
191
+ export interface ClaimOptions {
192
+ /** Minimum gap (seconds) between schedule runs; protects against double-fire. */
193
+ minIntervalSeconds: number;
194
+ }
195
+
196
+ /**
197
+ * Acquire a schedule's lockfile, verify min-interval, and call `fn` with the
198
+ * locked Schedule. The caller mutates last_run_at via `markScheduleRun`
199
+ * before the lock is dropped. If another worker holds the lock or the
200
+ * schedule ran too recently, returns null without calling `fn`.
201
+ *
202
+ * Important: enabled and last_run_at are checked BOTH before and after lock
203
+ * acquisition. The pre-lock read is a cheap fast-fail; the post-lock read
204
+ * is what `fn` actually receives, so a schedule that gets disabled, deleted,
205
+ * or just-fired between the cheap-check and the lock acquisition does not
206
+ * leak through.
207
+ */
208
+ export async function withScheduleLock<T>(
209
+ projectDir: string,
210
+ id: string,
211
+ workerId: string,
212
+ opts: ClaimOptions,
213
+ fn: (s: Schedule) => Promise<T>,
214
+ ): Promise<T | null> {
215
+ // Pre-lock fast path: skip work if obviously not eligible.
216
+ const pre = await getSchedule(projectDir, id);
217
+ if (!pre?.enabled) return null;
218
+ if (pre.last_run_at) {
219
+ const last = Date.parse(pre.last_run_at);
220
+ if (Date.now() - last < opts.minIntervalSeconds * 1000) return null;
221
+ }
222
+
223
+ const lockPath = scheduleLockPath(projectDir, id);
224
+ try {
225
+ await acquireLock(lockPath, workerId);
226
+ } catch (err) {
227
+ if (err instanceof LockHeldError) return null;
228
+ throw err;
229
+ }
230
+ try {
231
+ // Re-read under the lock. The schedule may have been disabled, deleted,
232
+ // or fired by another worker between the pre-check and the lock.
233
+ const fresh = await getSchedule(projectDir, id);
234
+ if (!fresh?.enabled) return null;
235
+ if (fresh.last_run_at) {
236
+ const last = Date.parse(fresh.last_run_at);
237
+ if (Date.now() - last < opts.minIntervalSeconds * 1000) return null;
238
+ }
239
+ return await fn(fresh);
240
+ } finally {
241
+ await releaseLock(lockPath);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Update last_run_at on a schedule. Uses atomic-write-if-unchanged so a
247
+ * concurrent edit aborts the run instead of clobbering it.
248
+ */
249
+ export async function markScheduleRun(
250
+ projectDir: string,
251
+ id: string,
252
+ ): Promise<void> {
253
+ const s = await getSchedule(projectDir, id);
254
+ if (!s) return;
255
+ const fm: ScheduleFrontmatter = {
256
+ ...s,
257
+ last_run_at: new Date().toISOString(),
258
+ updated_at: new Date().toISOString(),
259
+ };
260
+ await atomicWriteIfUnchanged(
261
+ scheduleFilePath(projectDir, id),
262
+ serializeSchedule(fm, s.body),
263
+ s.mtimeMs,
264
+ );
265
+ }
266
+
267
+ export async function reapOrphanScheduleLocks(
268
+ projectDir: string,
269
+ isWorkerAlive: (workerId: string) => Promise<boolean>,
270
+ ): Promise<string[]> {
271
+ const dir = getSchedulesLockDir(projectDir);
272
+ let names: string[];
273
+ try {
274
+ names = await readdir(dir);
275
+ } catch (err) {
276
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
277
+ throw err;
278
+ }
279
+ const released: string[] = [];
280
+ for (const name of names) {
281
+ if (!name.endsWith(".lock")) continue;
282
+ const id = name.slice(0, -".lock".length);
283
+ const lockPath = join(dir, name);
284
+ const holder = await readLockHolder(lockPath);
285
+ if (!holder) {
286
+ await releaseLock(lockPath);
287
+ released.push(id);
288
+ continue;
289
+ }
290
+ if (!(await isWorkerAlive(holder))) {
291
+ await releaseLock(lockPath);
292
+ released.push(id);
293
+ }
294
+ }
295
+ return released;
296
+ }
@@ -122,9 +122,7 @@ export function handleSlashCommand(
122
122
 
123
123
  if (name === "skills") {
124
124
  if (ctx.skills.size === 0) {
125
- ctx.addSystemMessage(
126
- "No skills loaded. Add .md files to .botholomew/skills/",
127
- );
125
+ ctx.addSystemMessage("No skills loaded. Add .md files to skills/");
128
126
  } else {
129
127
  const lines = ["Available skills:"];
130
128
  for (const [skillName, skill] of ctx.skills) {
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+
3
+ export const TASK_PRIORITIES = ["low", "medium", "high"] as const;
4
+ export const TASK_STATUSES = [
5
+ "pending",
6
+ "in_progress",
7
+ "failed",
8
+ "complete",
9
+ "waiting",
10
+ ] as const;
11
+
12
+ export type TaskPriority = (typeof TASK_PRIORITIES)[number];
13
+ export type TaskStatus = (typeof TASK_STATUSES)[number];
14
+
15
+ /**
16
+ * Frontmatter validator for `tasks/<id>.md`. Strict so a hand-edited or stale
17
+ * file doesn't silently round-trip with bad data; a parse failure quarantines
18
+ * the file (skip claim, log) per the doctor policy.
19
+ */
20
+ export const TaskFrontmatterSchema = z.object({
21
+ id: z.string().min(1),
22
+ name: z.string(),
23
+ description: z.string().default(""),
24
+ priority: z.enum(TASK_PRIORITIES).default("medium"),
25
+ status: z.enum(TASK_STATUSES).default("pending"),
26
+ blocked_by: z.array(z.string()).default([]),
27
+ context_paths: z.array(z.string()).default([]),
28
+ output: z.string().nullable().default(null),
29
+ waiting_reason: z.string().nullable().default(null),
30
+ claimed_by: z.string().nullable().default(null),
31
+ claimed_at: z.string().nullable().default(null),
32
+ created_at: z.string(),
33
+ updated_at: z.string(),
34
+ });
35
+
36
+ export type TaskFrontmatter = z.infer<typeof TaskFrontmatterSchema>;
37
+
38
+ /**
39
+ * In-memory task representation: frontmatter parsed + filesystem mtime so
40
+ * callers can detect concurrent edits before committing a write.
41
+ */
42
+ export interface Task extends TaskFrontmatter {
43
+ /** Filesystem mtime in epoch ms, used for atomic-write-if-unchanged. */
44
+ mtimeMs: number;
45
+ /** Markdown body (everything after the frontmatter). */
46
+ body: string;
47
+ }