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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/chat/agent.ts CHANGED
@@ -52,9 +52,11 @@ const CHAT_TOOL_NAMES = new Set([
52
52
  "view_thread",
53
53
  "search_threads",
54
54
  "create_schedule",
55
+ "schedule_edit",
55
56
  "list_schedules",
56
- "update_beliefs",
57
- "update_goals",
57
+ "prompt_read",
58
+ "prompt_edit",
59
+ "task_edit",
58
60
  "capabilities_refresh",
59
61
  "mcp_list_tools",
60
62
  "mcp_search",
@@ -100,7 +102,7 @@ You do NOT execute long-running work directly — enqueue tasks for a background
100
102
  Use the available tools to look up tasks, threads, schedules, and context when the user asks about them. Files the agent can read and write live under \`context/\` as project-relative paths (e.g. \`notes/foo.md\`). Use \`context_tree\` to see what's there, \`search\` (hybrid regexp + semantic) to find content, then \`context_read\` / \`context_info\` to drill in.
101
103
  Past conversations live in CSV files under \`threads/\`; use \`list_threads\`, \`search_threads\`, and \`view_thread\` to find and page through them.
102
104
  When multiple tool calls are independent of each other (i.e., one does not depend on the result of another), call them all in a single response. They will be executed in parallel, which is faster than calling them one at a time.
103
- You can update the agent's beliefs and goals files when the user asks you to.
105
+ You can read and edit the agent's prompt files (beliefs, goals, capabilities, soul) under \`prompts/\` via \`prompt_read\` and \`prompt_edit\` (line-range patches). Files marked \`agent-modification: false\` (e.g. soul.md) are read-only.
104
106
  You can author and refine slash-command skills (reusable prompt templates stored in \`skills/\`) via \`skill_list\`, \`skill_search\`, \`skill_read\`, \`skill_write\`, \`skill_edit\`, and \`skill_delete\`. New or edited skills are usable as \`/<name>\` on the user's next message.
105
107
  Format your responses using Markdown. Use headings, bold, italic, lists, and code blocks to make your responses clear and well-structured.
106
108
  `;
@@ -252,7 +254,7 @@ export async function runChatTurn(input: {
252
254
  // Rebuild the system prompt every iteration so that:
253
255
  // (1) `loading: contextual` files get matched against the latest user
254
256
  // message, and
255
- // (2) any update_beliefs / update_goals tool call in the previous
257
+ // (2) any prompt_edit tool call in the previous
256
258
  // iteration is reflected in the next LLM call.
257
259
  const keywordSource = findLastUserText(messages);
258
260
  const systemPrompt = await buildChatSystemPrompt(projectDir, {
@@ -0,0 +1,146 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { CONTEXT_DIR, LOCKS_SUBDIR } from "../constants.ts";
5
+ import {
6
+ acquireLock,
7
+ LockHeldError,
8
+ readLockHolder,
9
+ releaseLock,
10
+ } from "../fs/atomic.ts";
11
+
12
+ /**
13
+ * Per-path mutex for `context/` mutations. Tasks/schedules already serialize
14
+ * their own writes via O_EXCL lockfiles; this gives the same guarantee for
15
+ * `context_write` / `context_edit` / `context_delete` / `context_mv` so two
16
+ * tools (worker + chat, or two workers on the same path) can't race on
17
+ * read-modify-write or rename ordering.
18
+ *
19
+ * Lockfiles live at `<projectDir>/context/.locks/<sha1(path)>.lock`. We hash
20
+ * the path so the lock filename is bounded-length and slash-free, and so a
21
+ * leading-dot path doesn't accidentally collide with `walk()`'s dotfile skip
22
+ * in `src/context/store.ts`. The `.locks/` dir itself is invisible to
23
+ * `context_list` (walk skips dot-prefixed names at every depth).
24
+ */
25
+
26
+ // Retries are exponential-ish with jitter. Total worst-case wait is
27
+ // ~5 seconds — comfortable for a small herd of concurrent writers (the
28
+ // per-path critical section is just a stat + tmp write + rename, on the
29
+ // order of 1-10 ms each), and short enough that a stuck holder surfaces
30
+ // to the caller instead of hanging an LLM tool call indefinitely.
31
+ const ACQUIRE_RETRIES = 32;
32
+ const ACQUIRE_BASE_BACKOFF_MS = 10;
33
+ const ACQUIRE_MAX_BACKOFF_MS = 200;
34
+
35
+ export function getContextLocksDir(projectDir: string): string {
36
+ return join(projectDir, CONTEXT_DIR, LOCKS_SUBDIR);
37
+ }
38
+
39
+ export function contextLockPath(
40
+ projectDir: string,
41
+ normalizedPath: string,
42
+ ): string {
43
+ const hash = createHash("sha1").update(normalizedPath).digest("hex");
44
+ return join(getContextLocksDir(projectDir), `${hash}.lock`);
45
+ }
46
+
47
+ /**
48
+ * Run `fn` while holding the per-path context lock. Retries a few times with
49
+ * a small backoff if another caller has the lock — concurrent context tools
50
+ * are expected to converge, not surface "try again" errors to the LLM.
51
+ *
52
+ * `holderId` is stored in the lockfile body so the reaper (and humans
53
+ * inspecting `context/.locks/`) can identify the owner. Pass the worker id
54
+ * when called from a worker; chat sessions pass `"chat:<sessionId>"` or
55
+ * just `"chat"` — anything stable for the duration of the operation.
56
+ */
57
+ export async function withContextLock<T>(
58
+ projectDir: string,
59
+ normalizedPath: string,
60
+ holderId: string,
61
+ fn: () => Promise<T>,
62
+ ): Promise<T> {
63
+ const lockPath = contextLockPath(projectDir, normalizedPath);
64
+ for (let attempt = 0; ; attempt++) {
65
+ try {
66
+ await acquireLock(lockPath, holderId);
67
+ try {
68
+ return await fn();
69
+ } finally {
70
+ await releaseLock(lockPath);
71
+ }
72
+ } catch (err) {
73
+ if (err instanceof LockHeldError && attempt < ACQUIRE_RETRIES) {
74
+ const exp = Math.min(
75
+ ACQUIRE_MAX_BACKOFF_MS,
76
+ ACQUIRE_BASE_BACKOFF_MS * 2 ** attempt,
77
+ );
78
+ const jittered = exp * (0.5 + Math.random());
79
+ await new Promise((res) => setTimeout(res, jittered));
80
+ continue;
81
+ }
82
+ throw err;
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * True if `<projectDir>/context/.locks/<sha1(path)>.lock` currently exists.
89
+ * Used by the reindex orphan-prune to skip paths that a worker is mid-write
90
+ * on — without this guard the prune can drop the search-index rows of a
91
+ * file that's about to land on disk.
92
+ */
93
+ export async function isContextPathLocked(
94
+ projectDir: string,
95
+ normalizedPath: string,
96
+ ): Promise<boolean> {
97
+ try {
98
+ await stat(contextLockPath(projectDir, normalizedPath));
99
+ return true;
100
+ } catch (err) {
101
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
102
+ throw err;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Reaper: walk `context/.locks/`, drop any lockfile whose holder is no
108
+ * longer running per `isHolderAlive`. Mirrors `reapOrphanLocks` in
109
+ * `src/tasks/store.ts` so the worker reaper can clean stale context locks
110
+ * left behind by a crashed worker.
111
+ *
112
+ * `isHolderAlive` receives the raw holder id — the caller decides what
113
+ * counts as alive (typically: workers/<id>.json status === "running").
114
+ * Holders that don't match the worker convention (e.g. `"chat"` from a
115
+ * chat session) are conservatively treated as alive — not our business
116
+ * to expire those.
117
+ */
118
+ export async function reapOrphanContextLocks(
119
+ projectDir: string,
120
+ isHolderAlive: (holderId: string) => Promise<boolean>,
121
+ ): Promise<string[]> {
122
+ const dir = getContextLocksDir(projectDir);
123
+ let names: string[];
124
+ try {
125
+ names = await readdir(dir);
126
+ } catch (err) {
127
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
128
+ throw err;
129
+ }
130
+ const released: string[] = [];
131
+ for (const name of names) {
132
+ if (!name.endsWith(".lock")) continue;
133
+ const lockPath = join(dir, name);
134
+ const holder = await readLockHolder(lockPath);
135
+ if (!holder) {
136
+ await releaseLock(lockPath);
137
+ released.push(name);
138
+ continue;
139
+ }
140
+ if (!(await isHolderAlive(holder))) {
141
+ await releaseLock(lockPath);
142
+ released.push(name);
143
+ }
144
+ }
145
+ return released;
146
+ }
@@ -15,6 +15,7 @@ import {
15
15
  import { logger } from "../utils/logger.ts";
16
16
  import { chunkByTextSplit } from "./chunker.ts";
17
17
  import { embed as defaultEmbed } from "./embedder.ts";
18
+ import { isContextPathLocked } from "./locks.ts";
18
19
  import { listContextDir } from "./store.ts";
19
20
 
20
21
  /** Embed function shape — exported for tests that want to inject a fake. */
@@ -110,8 +111,16 @@ export async function reindexContext(
110
111
  }
111
112
 
112
113
  // 4. Anything left in indexedByPath is in the index but not on disk →
113
- // delete its rows so search results don't surface ghost files.
114
+ // delete its rows so search results don't surface ghost files. Skip
115
+ // paths with an active per-path write lock: a worker may have just
116
+ // written the file *after* our `collectDiskFiles` walk snapshot, and
117
+ // pruning now would drop the index row for a real file. Best-effort —
118
+ // the next reindex will reconcile.
114
119
  for (const orphan of indexedByPath.keys()) {
120
+ if (await isContextPathLocked(projectDir, orphan)) {
121
+ logger.debug(`reindex: skipping orphan-prune for in-flight ${orphan}`);
122
+ continue;
123
+ }
115
124
  await withDb(dbPath, (conn) => deleteIndexedPath(conn, orphan));
116
125
  removed++;
117
126
  }
@@ -12,13 +12,24 @@ 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";
21
+ import { applyLinePatches, type LinePatch } from "../fs/patches.ts";
16
22
  import {
17
23
  getCanonicalRoot,
18
24
  PathEscapeError,
19
25
  resolveInRoot,
20
26
  toRelativePath,
21
27
  } from "../fs/sandbox.ts";
28
+ import { withContextLock } from "./locks.ts";
29
+
30
+ function defaultHolderId(): string {
31
+ return `pid:${process.pid}`;
32
+ }
22
33
 
23
34
  /**
24
35
  * Disk-backed replacement for the old DuckDB context_items CRUD layer. All
@@ -59,11 +70,7 @@ export class PathConflictError extends Error {
59
70
  }
60
71
  }
61
72
 
62
- export interface Patch {
63
- start_line: number;
64
- end_line: number;
65
- content: string;
66
- }
73
+ export type Patch = LinePatch;
67
74
 
68
75
  export interface ContextEntry {
69
76
  /** Project-relative path under context/, e.g. "notes/foo.md". Forward-slashes. */
@@ -313,7 +320,10 @@ export async function writeContextFile(
313
320
  projectDir: string,
314
321
  path: string,
315
322
  content: string,
316
- opts: { onConflict?: "error" | "overwrite" } = {},
323
+ opts: {
324
+ onConflict?: "error" | "overwrite";
325
+ holderId?: string;
326
+ } = {},
317
327
  ): Promise<ContextEntry> {
318
328
  const abs = await resolveContext(projectDir, path);
319
329
  const normalized = normalizeContextPath(path);
@@ -324,28 +334,35 @@ export async function writeContextFile(
324
334
  );
325
335
  }
326
336
  const conflict = opts.onConflict ?? "overwrite";
327
- let exists = false;
328
- try {
329
- const st = await stat(abs);
330
- if (st.isDirectory()) throw new IsDirectoryError(normalized);
331
- exists = true;
332
- } catch (err) {
333
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
334
- }
335
- if (exists && conflict === "error") {
336
- throw new PathConflictError(normalized);
337
- }
338
- await mkdir(dirname(abs), { recursive: true });
339
- await atomicWrite(abs, content);
340
- const entry = await getInfo(projectDir, normalized);
341
- if (!entry) throw new Error(`Wrote ${normalized} but could not stat`);
342
- 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
+ );
343
360
  }
344
361
 
345
362
  export async function deleteContextPath(
346
363
  projectDir: string,
347
364
  path: string,
348
- opts: { recursive?: boolean } = {},
365
+ opts: { recursive?: boolean; holderId?: string } = {},
349
366
  ): Promise<{ removed: number; was_directory: boolean; was_symlink: boolean }> {
350
367
  const abs = await resolveContext(projectDir, path, {
351
368
  allowSymlinkLeaf: true,
@@ -354,61 +371,80 @@ export async function deleteContextPath(
354
371
  if (normalized === "") {
355
372
  throw new PathEscapeError("refusing to delete the context root", path);
356
373
  }
357
- let lst: Awaited<ReturnType<typeof lstat>>;
358
- try {
359
- lst = await lstat(abs);
360
- } catch (err) {
361
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
362
- throw new NotFoundError(normalized);
363
- }
364
- throw err;
365
- }
366
- // A symlink (to a file or a directory, broken or not) is removed with a
367
- // plain unlink — never follow into the target. This is what enforces
368
- // "the symlink can be deleted, but not the original content".
369
- if (lst.isSymbolicLink()) {
370
- await unlink(abs);
371
- return { removed: 1, was_directory: false, was_symlink: true };
372
- }
373
- if (lst.isDirectory()) {
374
- if (!opts.recursive) {
375
- throw new IsDirectoryError(normalized);
376
- }
377
- const removedPaths = await collectFiles(abs);
378
- await rm(abs, { recursive: true, force: false });
379
- return {
380
- removed: removedPaths.length,
381
- was_directory: true,
382
- was_symlink: false,
383
- };
384
- }
385
- await unlink(abs);
386
- 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
+ );
387
411
  }
388
412
 
389
413
  export async function moveContextPath(
390
414
  projectDir: string,
391
415
  src: string,
392
416
  dst: string,
417
+ opts: { holderId?: string } = {},
393
418
  ): Promise<void> {
394
419
  const srcAbs = await resolveContext(projectDir, src);
395
420
  const dstAbs = await resolveContext(projectDir, dst);
396
- try {
397
- await stat(srcAbs);
398
- } catch (err) {
399
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
400
- throw new NotFoundError(normalizeContextPath(src));
401
- }
402
- throw err;
403
- }
404
- try {
405
- await stat(dstAbs);
406
- throw new PathConflictError(normalizeContextPath(dst));
407
- } catch (err) {
408
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
409
- }
410
- await mkdir(dirname(dstAbs), { recursive: true });
411
- 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
+ );
412
448
  }
413
449
 
414
450
  export async function copyContextPath(
@@ -773,30 +809,26 @@ export async function applyPatches(
773
809
  projectDir: string,
774
810
  path: string,
775
811
  patches: Patch[],
812
+ opts: { holderId?: string } = {},
776
813
  ): Promise<{ applied: number; lines: number }> {
777
- const content = await readContextFile(projectDir, path);
778
- const lines = content.split("\n");
779
-
780
- const sorted = [...patches].sort((a, b) => b.start_line - a.start_line);
781
-
782
- for (const patch of sorted) {
783
- if (patch.end_line === 0) {
784
- const insertLines = patch.content === "" ? [] : patch.content.split("\n");
785
- lines.splice(patch.start_line - 1, 0, ...insertLines);
786
- } else {
787
- const deleteCount = patch.end_line - patch.start_line + 1;
788
- const insertLines = patch.content === "" ? [] : patch.content.split("\n");
789
- lines.splice(patch.start_line - 1, deleteCount, ...insertLines);
790
- }
791
- }
792
-
793
- const newContent = lines.join("\n");
794
- await writeContextFile(projectDir, path, newContent, {
795
- 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 };
796
827
  });
797
- return { applied: patches.length, lines: lines.length };
798
828
  }
799
829
 
830
+ export { MtimeConflictError };
831
+
800
832
  /**
801
833
  * Convert an absolute filesystem path back to a context-relative path. Used
802
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();
@@ -0,0 +1,39 @@
1
+ import { z } from "zod";
2
+
3
+ export interface LinePatch {
4
+ start_line: number;
5
+ end_line: number;
6
+ content: string;
7
+ }
8
+
9
+ export const LinePatchSchema = z.object({
10
+ start_line: z.number().describe("1-based inclusive start line"),
11
+ end_line: z
12
+ .number()
13
+ .describe("1-based inclusive end line (0 to insert without replacing)"),
14
+ content: z
15
+ .string()
16
+ .describe("Replacement text (empty string to delete lines)"),
17
+ });
18
+
19
+ /**
20
+ * Apply git-style line-range patches to a string. Patches are applied
21
+ * bottom-up so earlier line numbers stay stable. `end_line === 0` is an
22
+ * insert that doesn't replace; an empty `content` deletes.
23
+ */
24
+ export function applyLinePatches(raw: string, patches: LinePatch[]): string {
25
+ const lines = raw.split("\n");
26
+ const sorted = [...patches].sort((a, b) => b.start_line - a.start_line);
27
+
28
+ for (const patch of sorted) {
29
+ const insertLines = patch.content === "" ? [] : patch.content.split("\n");
30
+ if (patch.end_line === 0) {
31
+ lines.splice(patch.start_line - 1, 0, ...insertLines);
32
+ } else {
33
+ const deleteCount = patch.end_line - patch.start_line + 1;
34
+ lines.splice(patch.start_line - 1, deleteCount, ...insertLines);
35
+ }
36
+ }
37
+
38
+ return lines.join("\n");
39
+ }
@@ -19,7 +19,7 @@ import {
19
19
  ScheduleFrontmatterSchema,
20
20
  } from "./schema.ts";
21
21
 
22
- function scheduleFilePath(projectDir: string, id: string): string {
22
+ export function scheduleFilePath(projectDir: string, id: string): string {
23
23
  return join(getSchedulesDir(projectDir), `${id}.md`);
24
24
  }
25
25
 
@@ -27,20 +27,26 @@ function scheduleLockPath(projectDir: string, id: string): string {
27
27
  return join(getSchedulesLockDir(projectDir), `${id}.lock`);
28
28
  }
29
29
 
30
- function serializeSchedule(fm: ScheduleFrontmatter, body: string): string {
30
+ export function serializeSchedule(
31
+ fm: ScheduleFrontmatter,
32
+ body: string,
33
+ ): string {
31
34
  return matter.stringify(`\n${body.trim()}\n`, fm as Record<string, unknown>);
32
35
  }
33
36
 
34
- interface ParseOk {
37
+ export interface ScheduleParseOk {
35
38
  ok: true;
36
39
  schedule: Schedule;
37
40
  }
38
- interface ParseFail {
41
+ export interface ScheduleParseFail {
39
42
  ok: false;
40
43
  reason: string;
41
44
  }
42
45
 
43
- function parseScheduleFile(raw: string, mtimeMs: number): ParseOk | ParseFail {
46
+ export function parseScheduleFile(
47
+ raw: string,
48
+ mtimeMs: number,
49
+ ): ScheduleParseOk | ScheduleParseFail {
44
50
  let parsed: matter.GrayMatterFile<string>;
45
51
  try {
46
52
  parsed = matter(raw);
@@ -21,7 +21,7 @@ import {
21
21
  type TaskStatus,
22
22
  } from "./schema.ts";
23
23
 
24
- function taskFilePath(projectDir: string, id: string): string {
24
+ export function taskFilePath(projectDir: string, id: string): string {
25
25
  return join(getTasksDir(projectDir), `${id}.md`);
26
26
  }
27
27
 
@@ -33,23 +33,23 @@ function taskLockPath(projectDir: string, id: string): string {
33
33
  * Render a Task to its on-disk markdown form. Frontmatter contains every
34
34
  * field; the body is preserved as-is. Trailing newline keeps line count sane.
35
35
  */
36
- function serializeTask(fm: TaskFrontmatter, body: string): string {
36
+ export function serializeTask(fm: TaskFrontmatter, body: string): string {
37
37
  return matter.stringify(`\n${body.trim()}\n`, fm as Record<string, unknown>);
38
38
  }
39
39
 
40
- interface ParseResult {
40
+ export interface TaskParseOk {
41
41
  ok: true;
42
42
  task: Task;
43
43
  }
44
- interface ParseFailure {
44
+ export interface TaskParseFail {
45
45
  ok: false;
46
46
  reason: string;
47
47
  }
48
48
 
49
- function parseTaskFile(
49
+ export function parseTaskFile(
50
50
  raw: string,
51
51
  mtimeMs: number,
52
- ): ParseResult | ParseFailure {
52
+ ): TaskParseOk | TaskParseFail {
53
53
  let parsed: matter.GrayMatterFile<string>;
54
54
  try {
55
55
  parsed = matter(raw);