botholomew 0.15.5 → 0.16.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.
@@ -12,7 +12,12 @@ import {
12
12
  } from "node:fs/promises";
13
13
  import { dirname, join, posix, relative, sep } from "node:path";
14
14
  import { CONTEXT_DIR, PROTECTED_AREAS } from "../constants.ts";
15
- import { atomicWrite } from "../fs/atomic.ts";
15
+ import {
16
+ atomicWrite,
17
+ atomicWriteIfUnchanged,
18
+ MtimeConflictError,
19
+ readWithMtime,
20
+ } from "../fs/atomic.ts";
16
21
  import { applyLinePatches, type LinePatch } from "../fs/patches.ts";
17
22
  import {
18
23
  getCanonicalRoot,
@@ -20,6 +25,11 @@ import {
20
25
  resolveInRoot,
21
26
  toRelativePath,
22
27
  } from "../fs/sandbox.ts";
28
+ import { withContextLock } from "./locks.ts";
29
+
30
+ function defaultHolderId(): string {
31
+ return `pid:${process.pid}`;
32
+ }
23
33
 
24
34
  /**
25
35
  * Disk-backed replacement for the old DuckDB context_items CRUD layer. All
@@ -310,7 +320,10 @@ export async function writeContextFile(
310
320
  projectDir: string,
311
321
  path: string,
312
322
  content: string,
313
- opts: { onConflict?: "error" | "overwrite" } = {},
323
+ opts: {
324
+ onConflict?: "error" | "overwrite";
325
+ holderId?: string;
326
+ } = {},
314
327
  ): Promise<ContextEntry> {
315
328
  const abs = await resolveContext(projectDir, path);
316
329
  const normalized = normalizeContextPath(path);
@@ -321,28 +334,35 @@ export async function writeContextFile(
321
334
  );
322
335
  }
323
336
  const conflict = opts.onConflict ?? "overwrite";
324
- let exists = false;
325
- try {
326
- const st = await stat(abs);
327
- if (st.isDirectory()) throw new IsDirectoryError(normalized);
328
- exists = true;
329
- } catch (err) {
330
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
331
- }
332
- if (exists && conflict === "error") {
333
- throw new PathConflictError(normalized);
334
- }
335
- await mkdir(dirname(abs), { recursive: true });
336
- await atomicWrite(abs, content);
337
- const entry = await getInfo(projectDir, normalized);
338
- if (!entry) throw new Error(`Wrote ${normalized} but could not stat`);
339
- return entry;
337
+ return withContextLock(
338
+ projectDir,
339
+ normalized,
340
+ opts.holderId ?? defaultHolderId(),
341
+ async () => {
342
+ let exists = false;
343
+ try {
344
+ const st = await stat(abs);
345
+ if (st.isDirectory()) throw new IsDirectoryError(normalized);
346
+ exists = true;
347
+ } catch (err) {
348
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
349
+ }
350
+ if (exists && conflict === "error") {
351
+ throw new PathConflictError(normalized);
352
+ }
353
+ await mkdir(dirname(abs), { recursive: true });
354
+ await atomicWrite(abs, content);
355
+ const entry = await getInfo(projectDir, normalized);
356
+ if (!entry) throw new Error(`Wrote ${normalized} but could not stat`);
357
+ return entry;
358
+ },
359
+ );
340
360
  }
341
361
 
342
362
  export async function deleteContextPath(
343
363
  projectDir: string,
344
364
  path: string,
345
- opts: { recursive?: boolean } = {},
365
+ opts: { recursive?: boolean; holderId?: string } = {},
346
366
  ): Promise<{ removed: number; was_directory: boolean; was_symlink: boolean }> {
347
367
  const abs = await resolveContext(projectDir, path, {
348
368
  allowSymlinkLeaf: true,
@@ -351,61 +371,80 @@ export async function deleteContextPath(
351
371
  if (normalized === "") {
352
372
  throw new PathEscapeError("refusing to delete the context root", path);
353
373
  }
354
- let lst: Awaited<ReturnType<typeof lstat>>;
355
- try {
356
- lst = await lstat(abs);
357
- } catch (err) {
358
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
359
- throw new NotFoundError(normalized);
360
- }
361
- throw err;
362
- }
363
- // A symlink (to a file or a directory, broken or not) is removed with a
364
- // plain unlink — never follow into the target. This is what enforces
365
- // "the symlink can be deleted, but not the original content".
366
- if (lst.isSymbolicLink()) {
367
- await unlink(abs);
368
- return { removed: 1, was_directory: false, was_symlink: true };
369
- }
370
- if (lst.isDirectory()) {
371
- if (!opts.recursive) {
372
- throw new IsDirectoryError(normalized);
373
- }
374
- const removedPaths = await collectFiles(abs);
375
- await rm(abs, { recursive: true, force: false });
376
- return {
377
- removed: removedPaths.length,
378
- was_directory: true,
379
- was_symlink: false,
380
- };
381
- }
382
- await unlink(abs);
383
- return { removed: 1, was_directory: false, was_symlink: false };
374
+ return withContextLock(
375
+ projectDir,
376
+ normalized,
377
+ opts.holderId ?? defaultHolderId(),
378
+ async () => {
379
+ let lst: Awaited<ReturnType<typeof lstat>>;
380
+ try {
381
+ lst = await lstat(abs);
382
+ } catch (err) {
383
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
384
+ throw new NotFoundError(normalized);
385
+ }
386
+ throw err;
387
+ }
388
+ // A symlink (to a file or a directory, broken or not) is removed with
389
+ // a plain unlink — never follow into the target. This is what enforces
390
+ // "the symlink can be deleted, but not the original content".
391
+ if (lst.isSymbolicLink()) {
392
+ await unlink(abs);
393
+ return { removed: 1, was_directory: false, was_symlink: true };
394
+ }
395
+ if (lst.isDirectory()) {
396
+ if (!opts.recursive) {
397
+ throw new IsDirectoryError(normalized);
398
+ }
399
+ const removedPaths = await collectFiles(abs);
400
+ await rm(abs, { recursive: true, force: false });
401
+ return {
402
+ removed: removedPaths.length,
403
+ was_directory: true,
404
+ was_symlink: false,
405
+ };
406
+ }
407
+ await unlink(abs);
408
+ return { removed: 1, was_directory: false, was_symlink: false };
409
+ },
410
+ );
384
411
  }
385
412
 
386
413
  export async function moveContextPath(
387
414
  projectDir: string,
388
415
  src: string,
389
416
  dst: string,
417
+ opts: { holderId?: string } = {},
390
418
  ): Promise<void> {
391
419
  const srcAbs = await resolveContext(projectDir, src);
392
420
  const dstAbs = await resolveContext(projectDir, dst);
393
- try {
394
- await stat(srcAbs);
395
- } catch (err) {
396
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
397
- throw new NotFoundError(normalizeContextPath(src));
398
- }
399
- throw err;
400
- }
401
- try {
402
- await stat(dstAbs);
403
- throw new PathConflictError(normalizeContextPath(dst));
404
- } catch (err) {
405
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
406
- }
407
- await mkdir(dirname(dstAbs), { recursive: true });
408
- await fsRename(srcAbs, dstAbs);
421
+ const srcNorm = normalizeContextPath(src);
422
+ const dstNorm = normalizeContextPath(dst);
423
+ // Acquire both locks in a stable order to avoid AB/BA deadlocks between
424
+ // concurrent moves that swap two paths. Sorted lexicographically.
425
+ const [firstNorm, secondNorm] =
426
+ srcNorm < dstNorm ? [srcNorm, dstNorm] : [dstNorm, srcNorm];
427
+ const holder = opts.holderId ?? defaultHolderId();
428
+ return withContextLock(projectDir, firstNorm, holder, () =>
429
+ withContextLock(projectDir, secondNorm, holder, async () => {
430
+ try {
431
+ await stat(srcAbs);
432
+ } catch (err) {
433
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
434
+ throw new NotFoundError(srcNorm);
435
+ }
436
+ throw err;
437
+ }
438
+ try {
439
+ await stat(dstAbs);
440
+ throw new PathConflictError(dstNorm);
441
+ } catch (err) {
442
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
443
+ }
444
+ await mkdir(dirname(dstAbs), { recursive: true });
445
+ await fsRename(srcAbs, dstAbs);
446
+ }),
447
+ );
409
448
  }
410
449
 
411
450
  export async function copyContextPath(
@@ -770,15 +809,26 @@ export async function applyPatches(
770
809
  projectDir: string,
771
810
  path: string,
772
811
  patches: Patch[],
812
+ opts: { holderId?: string } = {},
773
813
  ): Promise<{ applied: number; lines: number }> {
774
- const content = await readContextFile(projectDir, path);
775
- const newContent = applyLinePatches(content, patches);
776
- await writeContextFile(projectDir, path, newContent, {
777
- onConflict: "overwrite",
814
+ const abs = await resolveContext(projectDir, path);
815
+ const normalized = normalizeContextPath(path);
816
+ const holder = opts.holderId ?? defaultHolderId();
817
+ return withContextLock(projectDir, normalized, holder, async () => {
818
+ const read = await readWithMtime(abs);
819
+ if (!read) throw new NotFoundError(normalized);
820
+ const newContent = applyLinePatches(read.content, patches);
821
+ // The lock keeps other context tools out of this critical section, but
822
+ // an external editor (vim, IDE) can still mutate the file in parallel.
823
+ // The mtime guard catches that — agents and humans don't silently lose
824
+ // edits to each other.
825
+ await atomicWriteIfUnchanged(abs, newContent, read.mtimeMs);
826
+ return { applied: patches.length, lines: newContent.split("\n").length };
778
827
  });
779
- return { applied: patches.length, lines: newContent.split("\n").length };
780
828
  }
781
829
 
830
+ export { MtimeConflictError };
831
+
782
832
  /**
783
833
  * Convert an absolute filesystem path back to a context-relative path. Used
784
834
  * when rendering search hits or worker output that originated in store.ts.
package/src/fs/atomic.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { constants as fsConstants } from "node:fs";
2
3
  import {
3
4
  mkdir,
@@ -10,6 +11,17 @@ import {
10
11
  } from "node:fs/promises";
11
12
  import { dirname, join } from "node:path";
12
13
 
14
+ /**
15
+ * Build a temp suffix that is unique even when two callers in the same
16
+ * process race on the same target in the same millisecond. The 8 random
17
+ * bytes drown out any chance of `pid + Date.now()` collision and let the
18
+ * O_EXCL temp open in atomicWrite act as a real safety net rather than a
19
+ * suggestion.
20
+ */
21
+ function defaultTempSuffix(): string {
22
+ return `${process.pid}.${Date.now()}.${randomBytes(8).toString("hex")}`;
23
+ }
24
+
13
25
  /**
14
26
  * Write `content` to `targetPath` atomically: write to a sibling temp file,
15
27
  * fsync, then rename. The rename is atomic on POSIX same-filesystem; the
@@ -24,9 +36,17 @@ export async function atomicWrite(
24
36
  opts: { tempSuffix?: string } = {},
25
37
  ): Promise<void> {
26
38
  await mkdir(dirname(targetPath), { recursive: true });
27
- const suffix = opts.tempSuffix ?? `${process.pid}.${Date.now()}`;
39
+ const suffix = opts.tempSuffix ?? defaultTempSuffix();
28
40
  const tmp = `${targetPath}.tmp.${suffix}`;
29
- const fh = await open(tmp, "w", 0o644);
41
+ // O_EXCL surfaces a temp-file collision rather than letting two writers
42
+ // truncate each other's bytes. With the random default suffix this is the
43
+ // belt-and-suspenders guarantee that concurrent writes to the same target
44
+ // never silently lose data on the way to rename().
45
+ const fh = await open(
46
+ tmp,
47
+ fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
48
+ 0o644,
49
+ );
30
50
  try {
31
51
  if (typeof content === "string") {
32
52
  await fh.writeFile(content, "utf-8");
@@ -89,9 +109,13 @@ export async function atomicWriteIfUnchanged(
89
109
  opts: { tempSuffix?: string } = {},
90
110
  ): Promise<void> {
91
111
  await mkdir(dirname(targetPath), { recursive: true });
92
- const suffix = opts.tempSuffix ?? `${process.pid}.${Date.now()}`;
112
+ const suffix = opts.tempSuffix ?? defaultTempSuffix();
93
113
  const tmp = `${targetPath}.tmp.${suffix}`;
94
- const fh = await open(tmp, "w", 0o644);
114
+ const fh = await open(
115
+ tmp,
116
+ fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
117
+ 0o644,
118
+ );
95
119
  try {
96
120
  await fh.writeFile(content, "utf-8");
97
121
  await fh.sync();
package/src/init/index.ts CHANGED
@@ -36,7 +36,6 @@ import {
36
36
  DEFAULT_CONFIG,
37
37
  DEFAULT_MCPX_SERVERS,
38
38
  GOALS_MD,
39
- SOUL_MD,
40
39
  STANDUP_SKILL,
41
40
  SUMMARIZE_SKILL,
42
41
  } from "./templates.ts";
@@ -74,9 +73,8 @@ export async function initProject(
74
73
 
75
74
  // Persistent-context template files
76
75
  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
76
  await Bun.write(join(pcDir, "goals.md"), GOALS_MD);
77
+ await Bun.write(join(pcDir, "beliefs.md"), BELIEFS_MD);
80
78
  await Bun.write(join(pcDir, "capabilities.md"), CAPABILITIES_MD);
81
79
 
82
80
  // Default skills
@@ -117,7 +115,9 @@ export async function initProject(
117
115
  logger.dim("");
118
116
  logger.dim("Layout:");
119
117
  logger.dim(` ${CONFIG_DIR}/ settings`);
120
- logger.dim(` prompts/ soul, beliefs, goals, capabilities`);
118
+ logger.dim(
119
+ ` prompts/ goals, beliefs, capabilities (and any you add)`,
120
+ );
121
121
  logger.dim(` ${CONTEXT_DIR}/ agent-writable knowledge tree`);
122
122
  logger.dim(` ${TASKS_DIR}/ one markdown file per task`);
123
123
  logger.dim(` ${LOCKS_SUBDIR}/ worker claim lockfiles`);
@@ -1,20 +1,26 @@
1
1
  import { DEFAULT_CONFIG as SCHEMA_DEFAULT_CONFIG } from "../config/schemas.ts";
2
2
 
3
- export const SOUL_MD = `---
3
+ export const GOALS_MD = `---
4
+ title: Goals
4
5
  loading: always
5
- agent-modification: false
6
+ agent-modification: true
6
7
  ---
7
8
 
8
- # Soul
9
+ # Goals
9
10
 
10
11
  You are Botholomew, an AI agent for knowledge work, personified by a wise owl. You help humans manage information, research topics, organize knowledge, and complete intellectual tasks.
11
12
 
12
13
  You are thoughtful, thorough, and proactive. You work through your task queue methodically, prioritizing appropriately and asking for clarification when needed.
13
14
 
14
15
  You are direct: lead with the answer, skip preambles, disagree when you have reason to, and never flatter.
16
+
17
+ *The list below is the current set of goals for this project. Update it as goals are completed or new ones are added.*
18
+
19
+ - Get set up and ready to help.
15
20
  `;
16
21
 
17
22
  export const BELIEFS_MD = `---
23
+ title: Beliefs
18
24
  loading: always
19
25
  agent-modification: true
20
26
  ---
@@ -28,20 +34,8 @@ agent-modification: true
28
34
  - I should ask for help when I'm stuck rather than guessing.
29
35
  `;
30
36
 
31
- export const GOALS_MD = `---
32
- loading: always
33
- agent-modification: true
34
- ---
35
-
36
- # Goals
37
-
38
- *These are the current goals for this project.*
39
- *Botholomew updates this file as goals are completed or new ones are added.*
40
-
41
- - Get set up and ready to help.
42
- `;
43
-
44
37
  export const CAPABILITIES_MD = `---
38
+ title: Capabilities
45
39
  loading: always
46
40
  agent-modification: true
47
41
  ---
@@ -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,6 +2,7 @@ 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";
@@ -19,6 +20,7 @@ const outputSchema = z.object({
19
20
  is_error: z.boolean(),
20
21
  error_type: z.string().optional(),
21
22
  message: z.string().optional(),
23
+ next_action_hint: z.string().optional(),
22
24
  });
23
25
 
24
26
  export const contextEditTool = {
@@ -34,6 +36,7 @@ export const contextEditTool = {
34
36
  ctx.projectDir,
35
37
  input.path,
36
38
  input.patches,
39
+ { holderId: ctx.workerId },
37
40
  );
38
41
  const content = await readContextFile(ctx.projectDir, input.path);
39
42
  return { applied, content, is_error: false };
@@ -56,6 +59,17 @@ export const contextEditTool = {
56
59
  message: `context/${err.path} is a directory`,
57
60
  };
58
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
+ }
59
73
  throw err;
60
74
  }
61
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,136 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { getPromptsDir } from "../../constants.ts";
5
+ import { atomicWrite } from "../../fs/atomic.ts";
6
+ import {
7
+ PromptValidationError,
8
+ parsePromptFile,
9
+ serializePromptFile,
10
+ } from "../../utils/frontmatter.ts";
11
+ import type { ToolDefinition } from "../tool.ts";
12
+
13
+ const inputSchema = z.object({
14
+ name: z
15
+ .string()
16
+ .min(1)
17
+ .describe(
18
+ "Prompt name without extension (e.g. 'style-notes'). Resolves to prompts/<name>.md.",
19
+ ),
20
+ title: z
21
+ .string()
22
+ .min(1)
23
+ .describe("Human-readable title shown in prompt_list output."),
24
+ loading: z
25
+ .enum(["always", "contextual"])
26
+ .describe(
27
+ "'always' includes the prompt in every system prompt. 'contextual' includes it only when the latest user/task text shares keywords with the body.",
28
+ ),
29
+ agent_modification: z
30
+ .boolean()
31
+ .describe(
32
+ "If true, prompt_edit and prompt_delete may modify or remove this file. If false, the file is read-only to the agent.",
33
+ ),
34
+ body: z
35
+ .string()
36
+ .describe("Markdown body (everything after the frontmatter)."),
37
+ on_conflict: z
38
+ .enum(["error", "overwrite"])
39
+ .optional()
40
+ .default("error")
41
+ .describe(
42
+ "What to do if a prompt with this name already exists. Defaults to 'error'.",
43
+ ),
44
+ });
45
+
46
+ const outputSchema = z.object({
47
+ name: z.string().nullable(),
48
+ path: z.string().nullable(),
49
+ created: z.boolean(),
50
+ content: z.string(),
51
+ is_error: z.boolean(),
52
+ error_type: z.string().optional(),
53
+ message: z.string().optional(),
54
+ next_action_hint: z.string().optional(),
55
+ });
56
+
57
+ const VALID_NAME = /^[a-zA-Z0-9._-]+$/;
58
+
59
+ export const promptCreateTool = {
60
+ name: "prompt_create",
61
+ description:
62
+ "[[ bash equivalent command: touch ]] Create a new prompt file under prompts/. Frontmatter (title, loading, agent-modification) is set from the arguments and re-validated before the file is committed. Fails with path_conflict if a prompt with this name exists unless on_conflict='overwrite'.",
63
+ group: "context",
64
+ inputSchema,
65
+ outputSchema,
66
+ execute: async (input, ctx) => {
67
+ if (!VALID_NAME.test(input.name) || input.name.includes("..")) {
68
+ return {
69
+ name: null,
70
+ path: null,
71
+ created: false,
72
+ content: "",
73
+ is_error: true,
74
+ error_type: "invalid_name",
75
+ message: `Invalid prompt name: ${input.name}`,
76
+ next_action_hint:
77
+ "Use [a-zA-Z0-9._-] only — no slashes, no '..', no extension.",
78
+ };
79
+ }
80
+
81
+ const dir = getPromptsDir(ctx.projectDir);
82
+ const filePath = join(dir, `${input.name}.md`);
83
+ const exists = await Bun.file(filePath).exists();
84
+ if (exists && input.on_conflict !== "overwrite") {
85
+ return {
86
+ name: input.name,
87
+ path: filePath,
88
+ created: false,
89
+ content: "",
90
+ is_error: true,
91
+ error_type: "path_conflict",
92
+ message: `Prompt already exists: prompts/${input.name}.md`,
93
+ next_action_hint:
94
+ "Pass on_conflict='overwrite' to replace, or use prompt_edit for a partial change.",
95
+ };
96
+ }
97
+
98
+ const meta = {
99
+ title: input.title,
100
+ loading: input.loading,
101
+ "agent-modification": input.agent_modification,
102
+ };
103
+ const serialized = serializePromptFile(meta, input.body);
104
+
105
+ // Round-trip validation: refuse to write content that wouldn't load back.
106
+ try {
107
+ parsePromptFile(filePath, serialized);
108
+ } catch (err) {
109
+ return {
110
+ name: input.name,
111
+ path: filePath,
112
+ created: false,
113
+ content: serialized,
114
+ is_error: true,
115
+ error_type: "invalid_frontmatter",
116
+ message:
117
+ err instanceof PromptValidationError
118
+ ? err.message
119
+ : `Generated content failed validation: ${err instanceof Error ? err.message : String(err)}`,
120
+ next_action_hint:
121
+ "Pick a title without unusual characters that break YAML.",
122
+ };
123
+ }
124
+
125
+ await mkdir(dir, { recursive: true });
126
+ await atomicWrite(filePath, serialized);
127
+
128
+ return {
129
+ name: input.name,
130
+ path: filePath,
131
+ created: !exists,
132
+ content: serialized,
133
+ is_error: false,
134
+ };
135
+ },
136
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;