@llmkb/claude-code 0.1.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.
package/lib/sync.ts ADDED
@@ -0,0 +1,437 @@
1
+ /** File sync engine for ``llmkb sync``.
2
+
3
+ Walks a directory, applies ``.gitignore`` + ``.llmkbignore`` filtering,
4
+ computes SHA256 hashes, uploads new/changed files via TUS, and detects
5
+ renames by content identity.
6
+
7
+ Batch ingestion: Creates one ``IngestionJob`` upfront, uploads all files
8
+ into it via ``/complete?postpone_ingestion=true&ingestion_job_id=``, then
9
+ prints the job URL for the user to track progress in the UI.
10
+ */
11
+
12
+ import { readdir, stat as fsStat, readFile } from "node:fs/promises";
13
+ import { join, relative, resolve } from "node:path";
14
+ import ignore from "ignore";
15
+ import * as tus from "tus-js-client";
16
+ import { existsSync } from "node:fs";
17
+ import { getConfig } from "./config.js";
18
+ import { getToken } from "./credentials.js";
19
+ import { readSpaceConfig } from "./parser.js";
20
+ import { computeSha256 } from "./sync-state.js";
21
+ import { findProjectRoot } from "./parser.js";
22
+ import type { SyncState, FileChangeSet } from "./types.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const IGNORE_FILES = [".gitignore", ".llmkbignore"] as const;
29
+
30
+ /** Default patterns always skipped (binary, hidden dirs, large artifacts, test artifacts).
31
+ *
32
+ * Python-side mirror at ``api/app/mcp/tools/entity_filter.py`` in the llmkb-ai repo.
33
+ * Keep both lists in sync. */
34
+ const DEFAULT_SKIP_PATTERNS = [
35
+ "node_modules/**",
36
+ ".git/**",
37
+ ".llmkb/**",
38
+ ".claude/**",
39
+ ".cursor/**",
40
+ ".next/**",
41
+ "dist/**",
42
+ ".venv/**",
43
+ "__pycache__/**",
44
+ "*.pyc",
45
+ "*.exe",
46
+ "*.dll",
47
+ "*.so",
48
+ "*.dylib",
49
+ "*.bin",
50
+ // Test & ephemeral artifacts — exclude by default to avoid polluting
51
+ // the knowledge graph with test fixtures, snapshots, and build artifacts.
52
+ "**/__tests__/**",
53
+ "**/__snapshots__/**",
54
+ "**/test/**",
55
+ "**/tests/**",
56
+ "**/*.test.*",
57
+ "**/*.spec.*",
58
+ "**/fixtures/**",
59
+ "**/coverage/**",
60
+ "*.log",
61
+ "tmp/**",
62
+ "temp/**",
63
+ ];
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // File Walking
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export interface WalkOptions {
70
+ /** Project root directory (for ignore file lookup). */
71
+ projectDir: string;
72
+ /** Target directory to walk (defaults to projectDir). */
73
+ targetDir?: string;
74
+ /** Whether to respect .gitignore. */
75
+ respectGitignore?: boolean;
76
+ /** Whether to respect .llmkbignore. */
77
+ respectLlmkbignore?: boolean;
78
+ }
79
+
80
+ export interface WalkResult {
81
+ files: Array<[string, string]>; // [relativePath, absolutePath]
82
+ skipped: Array<{ path: string; reason: string }>;
83
+ }
84
+
85
+ /** Build an ignore filter from .gitignore, .llmkbignore, and default patterns.
86
+
87
+ Reads ignore files from the project root first, then from the target
88
+ directory — patterns from either source are applied.
89
+ */
90
+ async function buildFilter(
91
+ projectDir: string,
92
+ targetDir: string,
93
+ respectGitignore: boolean,
94
+ respectLlmkbignore: boolean,
95
+ ): Promise<(path: string) => boolean> {
96
+ const ig = ignore().add(DEFAULT_SKIP_PATTERNS);
97
+
98
+ const searchDirs = [projectDir, targetDir];
99
+ const seen = new Set<string>();
100
+
101
+ for (const baseDir of searchDirs) {
102
+ for (const fileName of IGNORE_FILES) {
103
+ const shouldRespect =
104
+ fileName === ".gitignore" ? respectGitignore : respectLlmkbignore;
105
+ if (!shouldRespect) continue;
106
+
107
+ const filePath = join(baseDir, fileName);
108
+ if (seen.has(filePath)) continue;
109
+ seen.add(filePath);
110
+
111
+ if (existsSync(filePath)) {
112
+ // Skip directories that collide with ignore-file names
113
+ const st = await fsStat(filePath);
114
+ if (!st.isFile()) continue;
115
+ const content = await readFile(filePath, "utf-8");
116
+ ig.add(content);
117
+ }
118
+ }
119
+ }
120
+
121
+ return (testPath: string) => ig.ignores(testPath);
122
+ }
123
+
124
+ /** Recursively walk a directory, returning files filtered by ignore rules. */
125
+ export async function walkFiles(
126
+ opts: WalkOptions,
127
+ ): Promise<WalkResult> {
128
+ const projectDir = resolve(opts.projectDir);
129
+ const targetDir = resolve(opts.targetDir ?? projectDir);
130
+ const respectGitignore = opts.respectGitignore ?? true;
131
+ const respectLlmkbignore = opts.respectLlmkbignore ?? true;
132
+
133
+ const isIgnored = await buildFilter(projectDir, targetDir, respectGitignore, respectLlmkbignore);
134
+ const files: Array<[string, string]> = [];
135
+ const skipped: Array<{ path: string; reason: string }> = [];
136
+
137
+ async function walk(dir: string): Promise<void> {
138
+ let entries;
139
+ try {
140
+ entries = await readdir(dir, { withFileTypes: true });
141
+ } catch {
142
+ skipped.push({ path: relative(targetDir, dir), reason: "unreadable" });
143
+ return;
144
+ }
145
+
146
+ for (const entry of entries) {
147
+ const fullPath = join(dir, entry.name);
148
+ const relPath = relative(targetDir, fullPath);
149
+
150
+ if (entry.name.startsWith(".") && !IGNORE_FILES.includes(entry.name as typeof IGNORE_FILES[number])) {
151
+ skipped.push({ path: relPath, reason: "hidden" });
152
+ continue;
153
+ }
154
+
155
+ if (isIgnored(relPath)) {
156
+ skipped.push({ path: relPath, reason: "ignored" });
157
+ continue;
158
+ }
159
+
160
+ if (entry.isDirectory()) {
161
+ // Recurse into directories
162
+ await walk(fullPath);
163
+ } else if (entry.isFile()) {
164
+ files.push([relPath, fullPath]);
165
+ } else if (entry.isSymbolicLink()) {
166
+ // Follow symlinks — stat to verify it's a file, not a dir symlink
167
+ try {
168
+ const st = await fsStat(fullPath);
169
+ if (st.isFile()) {
170
+ files.push([relPath, fullPath]);
171
+ } else {
172
+ skipped.push({ path: relPath, reason: "symlink-to-dir" });
173
+ }
174
+ } catch {
175
+ skipped.push({ path: relPath, reason: "broken-symlink" });
176
+ }
177
+ } else {
178
+ skipped.push({ path: relPath, reason: "not-a-file" });
179
+ }
180
+ }
181
+ }
182
+
183
+ await walk(targetDir);
184
+ return { files, skipped };
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // TUS Upload
189
+ // ---------------------------------------------------------------------------
190
+
191
+ export interface UploadOptions {
192
+ /** Target space name. */
193
+ space: string;
194
+ /** Relative path of the file within the space. */
195
+ relativePath: string;
196
+ /** Absolute path to the file on disk. */
197
+ absolutePath: string;
198
+ /** llmkb API endpoint. */
199
+ endpoint: string;
200
+ /** Auth token. */
201
+ token: string;
202
+ /** Upload progress callback. */
203
+ onProgress?: (bytesSent: number, bytesTotal: number) => void;
204
+ }
205
+
206
+ /** Upload a single file via TUS protocol (chunks only, no finalization). Returns the upload URL. */
207
+ export async function tusUpload(
208
+ opts: UploadOptions,
209
+ ): Promise<string> {
210
+ // Safety check: verify it's a file, not a directory
211
+ const st = await fsStat(opts.absolutePath);
212
+ if (!st.isFile()) {
213
+ throw new Error(`Not a file: ${opts.relativePath}`);
214
+ }
215
+ const fileBuffer = await readFile(opts.absolutePath);
216
+ const fileName = opts.relativePath.split("/").pop() ?? opts.relativePath;
217
+
218
+ return new Promise<string>((resolve, reject) => {
219
+ const upload = new tus.Upload(
220
+ fileBuffer,
221
+ {
222
+ endpoint: `${opts.endpoint}/api/spaces/${opts.space}/uploads`,
223
+ metadata: {
224
+ filename: opts.relativePath,
225
+ filetype: "application/octet-stream",
226
+ },
227
+ headers: {
228
+ Authorization: `Bearer ${opts.token}`,
229
+ },
230
+ chunkSize: 5 * 1024 * 1024, // 5 MB chunks
231
+ retryDelays: [0, 1000, 3000, 5000],
232
+ removeFingerprintOnSuccess: true,
233
+ onError: (err: Error) => reject(err),
234
+ onProgress: (bytesSent: number, bytesTotal: number) => {
235
+ opts.onProgress?.(bytesSent, bytesTotal);
236
+ },
237
+ onSuccess: () => {
238
+ resolve(upload.url ?? "");
239
+ },
240
+ },
241
+ );
242
+ upload.start();
243
+ });
244
+ }
245
+
246
+ /** Finalize a TUS upload by calling /complete. Returns the upload URL on success. */
247
+ export async function finalizeUpload(
248
+ uploadUrl: string,
249
+ token: string,
250
+ ingestionJobId?: string,
251
+ ): Promise<string> {
252
+ const params = new URLSearchParams({ postpone_ingestion: "true" });
253
+ if (ingestionJobId) {
254
+ params.set("ingestion_job_id", ingestionJobId);
255
+ }
256
+ const completeUrl = `${uploadUrl}/complete?${params.toString()}`;
257
+ const res = await fetch(completeUrl, {
258
+ method: "POST",
259
+ headers: {
260
+ Authorization: `Bearer ${token}`,
261
+ "Tus-Resumable": "1.0.0",
262
+ },
263
+ });
264
+ if (!res.ok) {
265
+ const body = await res.text().catch(() => "");
266
+ const shortId = uploadUrl.split("/").pop() ?? "unknown";
267
+ throw new Error(`Upload completion failed for ${shortId} (${res.status}): ${body}`);
268
+ }
269
+ return uploadUrl;
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Sync Engine
274
+ // ---------------------------------------------------------------------------
275
+
276
+ export interface SyncOptions {
277
+ /** Path to sync (directory or file). */
278
+ path: string;
279
+ /** Comma-separated list of skip reasons to ignore (for --dry-run filtering). */
280
+ skipReasons?: string[];
281
+ /** Callback for each file upload attempt. */
282
+ onFile?: (relPath: string, status: "uploading" | "skipped" | "renamed" | "unchanged" | "failed", detail?: string) => void;
283
+ }
284
+
285
+ export interface SyncResult {
286
+ uploaded: number;
287
+ skipped: number;
288
+ unchanged: number;
289
+ renamed: number;
290
+ errors: Array<{ path: string; error: string }>;
291
+ /** Ingestion job ID(s) after batch finalization. */
292
+ ingestionJobIds?: string[];
293
+ /** URL of the first ingestion job (for the sync command to display). */
294
+ ingestionJobUrl?: string;
295
+ /** Machine-readable outcome: "synced" | "no_files" | "up_to_date". */
296
+ status?: "synced" | "no_files" | "up_to_date";
297
+ /** Human-readable explanation when status is not "synced". */
298
+ reason?: string;
299
+ }
300
+
301
+ /** Run the full sync: walk -> compare -> upload -> update state. */
302
+ export async function runSync(
303
+ space: string,
304
+ syncState: SyncState | null,
305
+ opts: SyncOptions,
306
+ ): Promise<SyncResult> {
307
+ const projectRoot = findProjectRoot();
308
+ if (!projectRoot) throw new Error("No .llmkb/ directory found. Run `llmkb init` first.");
309
+
310
+ const config = getConfig();
311
+ const endpoint = config.endpoint;
312
+ const token = await getToken();
313
+
314
+ const result: SyncResult = { uploaded: 0, skipped: 0, unchanged: 0, renamed: 0, errors: [] };
315
+
316
+ // Walk files
317
+ const walkResult = await walkFiles({
318
+ projectDir: projectRoot,
319
+ targetDir: resolve(opts.path),
320
+ });
321
+
322
+ for (const s of walkResult.skipped) {
323
+ result.skipped++;
324
+ opts.onFile?.(s.path, "skipped", s.reason);
325
+ }
326
+
327
+ if (walkResult.files.length === 0) {
328
+ result.status = "no_files";
329
+ const filteredCount = walkResult.skipped.length;
330
+ if (filteredCount > 0) {
331
+ result.reason = `No files to sync — all ${filteredCount} file(s) were filtered out by .gitignore / .llmkbignore rules.`;
332
+ } else {
333
+ result.reason = "No files found in sync target. The directory appears to be empty.";
334
+ }
335
+ return result;
336
+ }
337
+
338
+ // Classify files
339
+ const changes = await import("./sync-state.js").then((m) =>
340
+ m.getChangedFiles(walkResult.files, syncState),
341
+ );
342
+
343
+ // Process unchanged
344
+ for (const f of changes.unchangedFiles) {
345
+ result.unchanged++;
346
+ opts.onFile?.(f, "unchanged");
347
+ }
348
+
349
+ // Process renamed
350
+ for (const r of changes.renamed) {
351
+ result.renamed++;
352
+ opts.onFile?.(r.newPath, "renamed", `${r.oldPath} → ${r.newPath}`);
353
+ }
354
+
355
+ // Upload new + changed — collect upload URLs for batch finalization
356
+ const toUpload = [...changes.newFiles, ...changes.changedFiles];
357
+ if (toUpload.length === 0) {
358
+ result.status = "up_to_date";
359
+ result.reason = `No changes detected — all files are up to date (${walkResult.files.length} files, unchanged: ${result.unchanged}, renamed: ${result.renamed}, skipped: ${result.skipped}).`;
360
+ return result;
361
+ }
362
+ const uploadUrls: string[] = [];
363
+ const ingestionJobId: string | undefined = undefined;
364
+ // No pre-flight job creation — start_ingestion_batch on the backend
365
+ // handles job creation and document association. Passing undefined as
366
+ // ingestionJobId means /complete sets ingestion_job_id=NULL, and the
367
+ // batch endpoint picks up all pending docs regardless.
368
+
369
+ for (const relPath of toUpload) {
370
+ const absPath = join(resolve(opts.path), relPath);
371
+
372
+ if (!token) {
373
+ result.errors.push({ path: relPath, error: "No token found" });
374
+ opts.onFile?.(relPath, "failed", "no token");
375
+ continue;
376
+ }
377
+
378
+ try {
379
+ const absEntry = walkResult.files.find(([, a]) => a === absPath);
380
+ const absPathResolved = absEntry?.[1] ?? absPath;
381
+ const url = await tusUpload({
382
+ space,
383
+ relativePath: relPath,
384
+ absolutePath: absPathResolved,
385
+ endpoint,
386
+ token,
387
+ });
388
+ uploadUrls.push(url);
389
+ result.uploaded++;
390
+ opts.onFile?.(relPath, "uploading");
391
+ } catch (err) {
392
+ result.errors.push({ path: relPath, error: (err as Error).message });
393
+ opts.onFile?.(relPath, "failed", (err as Error).message);
394
+ }
395
+ }
396
+
397
+ // Finalize all uploads into the pre-created job
398
+ if (uploadUrls.length > 0) {
399
+ for (const url of uploadUrls) {
400
+ try {
401
+ await finalizeUpload(url, token!, ingestionJobId);
402
+ } catch (err) {
403
+ const shortId = url.split("/").pop() ?? "unknown";
404
+ result.errors.push({ path: shortId, error: (err as Error).message });
405
+ opts.onFile?.(shortId, "failed", (err as Error).message);
406
+ }
407
+ }
408
+ // Trigger the ingestion pipeline for all finalized documents
409
+ if (token) {
410
+ try {
411
+ const ingestRes = await fetch(
412
+ `${endpoint}/api/spaces/${space}/uploads/start-ingest`,
413
+ {
414
+ method: "POST",
415
+ headers: { Authorization: `Bearer ${token}` },
416
+ },
417
+ );
418
+ if (ingestRes.ok) {
419
+ const ingestBody = (await ingestRes.json()) as {
420
+ data?: { attributes?: { job_id?: string; url?: string } };
421
+ };
422
+ // Use the real job URL from the batch endpoint (more accurate
423
+ // than the pre-flight placeholder URL).
424
+ const realUrl = ingestBody.data?.attributes?.url;
425
+ if (realUrl) {
426
+ result.ingestionJobUrl = realUrl;
427
+ }
428
+ }
429
+ } catch {
430
+ // Non-fatal — documents are stored, ingestion can be triggered manually
431
+ }
432
+ }
433
+ }
434
+
435
+ result.status = "synced";
436
+ return result;
437
+ }
package/lib/types.ts ADDED
@@ -0,0 +1,153 @@
1
+ /** Shared TypeScript interfaces for the llmkb plugin config model.
2
+
3
+ These types define the schema for the per-project configuration files
4
+ (``.llmkb/spaces.yml`` and ``.llmkb/config.yml``) used by all CLI commands,
5
+ the MCP server config, and the sync engine.
6
+
7
+ Conventions:
8
+ - All field names follow the YAML key naming (lower_snake_case).
9
+ - Optional fields are marked with ``?``.
10
+ - Tokens MUST NOT appear in any config file — they live in the OS keychain or env vars.
11
+ */
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Space Definition (new PSM-4 format)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** A project space entry — the primary space for file sync. */
18
+ export interface ProjectSpaceDef {
19
+ /** Space UUID. */
20
+ id: string;
21
+ /** Optional human-readable name. */
22
+ name?: string;
23
+ /** Optional slug for MCP tool names. */
24
+ slug?: string;
25
+ /** Optional directory paths to sync from this project. */
26
+ dirs?: string[];
27
+ }
28
+
29
+ /** A referenced space entry (additional spaces beyond project_space). */
30
+ export interface SpaceDef {
31
+ /** Space UUID. */
32
+ id: string;
33
+ /** Optional human-readable name. */
34
+ name?: string;
35
+ /** Optional slug for MCP tool names. */
36
+ slug?: string;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Full Space Config (``.llmkb/spaces.yml``)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export interface SpaceConfig {
44
+ /** The project's primary space (file sync target). */
45
+ project_space?: ProjectSpaceDef[];
46
+ /** Additional spaces this project has access to. */
47
+ spaces?: SpaceDef[];
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Plugin Behavior Config (``.llmkb/config.yml``)
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export interface WatchSettings {
55
+ enabled?: boolean;
56
+ debounce_ms?: number;
57
+ gitignore?: boolean;
58
+ llmkbignore?: boolean;
59
+ default_verbosity?: "silent" | "verbose" | "debug";
60
+ }
61
+
62
+ export interface SyncSettings {
63
+ concurrency?: number;
64
+ retry_attempts?: number;
65
+ retry_delay_ms?: number;
66
+ }
67
+
68
+ export interface HookSettings {
69
+ timeout_ms?: number;
70
+ skip?: boolean;
71
+ }
72
+
73
+ export interface PluginConfig {
74
+ /** Backend API base URL (e.g. https://api.llmkb.ai). */
75
+ llmkb_base_url?: string;
76
+ watch?: WatchSettings;
77
+ sync?: SyncSettings;
78
+ hook?: HookSettings;
79
+ debug?: boolean;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Version Stamp
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /** The version file at ``.llmkb/.llmkb-version`` stores just the semver string. */
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Exported utilities
90
+ // ---------------------------------------------------------------------------
91
+
92
+ export const PACKAGE_VERSION = "0.1.0";
93
+
94
+ /** Returns the default plugin behavior config used when ``config.yml`` is missing or all fields are commented. */
95
+ export function getDefaultPluginConfig(): PluginConfig {
96
+ return {
97
+ llmkb_base_url: "https://api.llmkb.ai",
98
+ watch: {
99
+ enabled: true,
100
+ debounce_ms: 300,
101
+ gitignore: true,
102
+ llmkbignore: true,
103
+ default_verbosity: "silent",
104
+ },
105
+ sync: {
106
+ concurrency: 5,
107
+ retry_attempts: 3,
108
+ retry_delay_ms: 1000,
109
+ },
110
+ hook: {
111
+ timeout_ms: 5000,
112
+ skip: false,
113
+ },
114
+ debug: false,
115
+ };
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Token Types
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /** The only token type used by the plugin. */
123
+ export type TokenType = "access_token";
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Sync State Types
127
+ // ---------------------------------------------------------------------------
128
+
129
+ export type SyncFileStatus = "synced" | "pending" | "failed";
130
+
131
+ export interface SyncFileEntry {
132
+ sha256: string;
133
+ lastUploaded: string | null;
134
+ status: SyncFileStatus;
135
+ }
136
+
137
+ export interface SyncState {
138
+ space: string;
139
+ lastSyncAt: string;
140
+ files: Record<string, SyncFileEntry>;
141
+ }
142
+
143
+ export interface RenameEntry {
144
+ oldPath: string;
145
+ newPath: string;
146
+ }
147
+
148
+ export interface FileChangeSet {
149
+ newFiles: string[];
150
+ changedFiles: string[];
151
+ unchangedFiles: string[];
152
+ renamed: RenameEntry[];
153
+ }
@@ -0,0 +1,78 @@
1
+ /** Advisory file lock for chokidar watch mode.
2
+
3
+ Prevents duplicate watchers in the same project. Uses a simple PID-file
4
+ pattern: writes the current PID to ``.llmkb/.watch.lock`` and checks
5
+ liveness of the owning process before granting the lock.
6
+ */
7
+
8
+ import { writeFile, readFile, unlink } from "node:fs/promises";
9
+ import { existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { spawnSync } from "node:child_process";
12
+
13
+ const LOCK_FILE = ".llmkb/.watch.lock";
14
+
15
+ /**
16
+ * Acquire an advisory watch lock.
17
+ *
18
+ * Returns ``true`` if the lock was acquired (or if no project root was found).
19
+ * Returns ``false`` if another watcher holds the lock and its PID is still alive.
20
+ */
21
+ export async function acquireWatchLock(projectRoot: string): Promise<boolean> {
22
+ if (!projectRoot) return true;
23
+ const lockPath = join(projectRoot, LOCK_FILE);
24
+
25
+ if (existsSync(lockPath)) {
26
+ try {
27
+ const content = await readFile(lockPath, "utf-8");
28
+ const pid = parseInt(content.trim(), 10);
29
+
30
+ if (Number.isFinite(pid) && pid > 0) {
31
+ try {
32
+ // Check if process is alive (ESRCH = dead, EPERM = alive but cross-user)
33
+ process.kill(pid, 0);
34
+ // Process is alive — lock is held
35
+ return false;
36
+ } catch (e) {
37
+ // ESRCH means process is gone — we can take the lock
38
+ if ((e as NodeJS.ErrnoException).code !== "ESRCH") {
39
+ return false;
40
+ }
41
+ }
42
+ }
43
+ } catch {
44
+ // Unreadable or corrupt lock — allow override
45
+ }
46
+ }
47
+
48
+ await writeFile(lockPath, String(process.pid), "utf-8");
49
+ return true;
50
+ }
51
+
52
+ /**
53
+ * Release the advisory watch lock.
54
+ */
55
+ export async function releaseWatchLock(projectRoot: string): Promise<void> {
56
+ if (!projectRoot) return;
57
+ const lockPath = join(projectRoot, LOCK_FILE);
58
+
59
+ try {
60
+ // Only unlink if we still own it
61
+ if (existsSync(lockPath)) {
62
+ const content = await readFile(lockPath, "utf-8");
63
+ if (content.trim() === String(process.pid)) {
64
+ await unlink(lockPath);
65
+ }
66
+ }
67
+ } catch {
68
+ // Best-effort cleanup
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Check whether a watch lock currently exists.
74
+ */
75
+ export function hasWatchLock(projectRoot: string): boolean {
76
+ if (!projectRoot) return false;
77
+ return existsSync(join(projectRoot, LOCK_FILE));
78
+ }