@ddtcorex/dsh-maestro-memory 1.0.1

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 (109) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +21 -0
  3. package/README.md +448 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +756 -0
  6. package/lib/index.d.ts +10 -0
  7. package/lib/index.d.ts.map +1 -0
  8. package/lib/index.js +627 -0
  9. package/lib/index.js.map +1 -0
  10. package/lib/memory/batch.d.ts +44 -0
  11. package/lib/memory/batch.d.ts.map +1 -0
  12. package/lib/memory/batch.js +75 -0
  13. package/lib/memory/batch.js.map +1 -0
  14. package/lib/memory/feedback.d.ts +18 -0
  15. package/lib/memory/feedback.d.ts.map +1 -0
  16. package/lib/memory/feedback.js +29 -0
  17. package/lib/memory/feedback.js.map +1 -0
  18. package/lib/memory/store.d.ts +154 -0
  19. package/lib/memory/store.d.ts.map +1 -0
  20. package/lib/memory/store.js +606 -0
  21. package/lib/memory/store.js.map +1 -0
  22. package/lib/migration/cli.d.ts +18 -0
  23. package/lib/migration/cli.d.ts.map +1 -0
  24. package/lib/migration/cli.js +143 -0
  25. package/lib/migration/cli.js.map +1 -0
  26. package/lib/migration/fixture.d.ts +45 -0
  27. package/lib/migration/fixture.d.ts.map +1 -0
  28. package/lib/migration/fixture.js +177 -0
  29. package/lib/migration/fixture.js.map +1 -0
  30. package/lib/migration/service.d.ts +75 -0
  31. package/lib/migration/service.d.ts.map +1 -0
  32. package/lib/migration/service.js +713 -0
  33. package/lib/migration/service.js.map +1 -0
  34. package/lib/prompt/snapshot.d.ts +26 -0
  35. package/lib/prompt/snapshot.d.ts.map +1 -0
  36. package/lib/prompt/snapshot.js +74 -0
  37. package/lib/prompt/snapshot.js.map +1 -0
  38. package/lib/review/queue.d.ts +72 -0
  39. package/lib/review/queue.d.ts.map +1 -0
  40. package/lib/review/queue.js +306 -0
  41. package/lib/review/queue.js.map +1 -0
  42. package/lib/skills-browser.d.ts +67 -0
  43. package/lib/skills-browser.d.ts.map +1 -0
  44. package/lib/skills-browser.js +185 -0
  45. package/lib/skills-browser.js.map +1 -0
  46. package/lib/storage/atomic-store.d.ts +211 -0
  47. package/lib/storage/atomic-store.d.ts.map +1 -0
  48. package/lib/storage/atomic-store.js +582 -0
  49. package/lib/storage/atomic-store.js.map +1 -0
  50. package/lib/storage/layout.d.ts +81 -0
  51. package/lib/storage/layout.d.ts.map +1 -0
  52. package/lib/storage/layout.js +162 -0
  53. package/lib/storage/layout.js.map +1 -0
  54. package/lib/storage/legacy-format.d.ts +104 -0
  55. package/lib/storage/legacy-format.d.ts.map +1 -0
  56. package/lib/storage/legacy-format.js +262 -0
  57. package/lib/storage/legacy-format.js.map +1 -0
  58. package/lib/sync/config.d.ts +22 -0
  59. package/lib/sync/config.d.ts.map +1 -0
  60. package/lib/sync/config.js +57 -0
  61. package/lib/sync/config.js.map +1 -0
  62. package/lib/sync/git.d.ts +97 -0
  63. package/lib/sync/git.d.ts.map +1 -0
  64. package/lib/sync/git.js +282 -0
  65. package/lib/sync/git.js.map +1 -0
  66. package/lib/sync/index.d.ts +6 -0
  67. package/lib/sync/index.d.ts.map +1 -0
  68. package/lib/sync/index.js +6 -0
  69. package/lib/sync/index.js.map +1 -0
  70. package/lib/sync/layout.d.ts +15 -0
  71. package/lib/sync/layout.d.ts.map +1 -0
  72. package/lib/sync/layout.js +33 -0
  73. package/lib/sync/layout.js.map +1 -0
  74. package/lib/sync/merge.d.ts +42 -0
  75. package/lib/sync/merge.d.ts.map +1 -0
  76. package/lib/sync/merge.js +159 -0
  77. package/lib/sync/merge.js.map +1 -0
  78. package/lib/sync/service.d.ts +70 -0
  79. package/lib/sync/service.d.ts.map +1 -0
  80. package/lib/sync/service.js +508 -0
  81. package/lib/sync/service.js.map +1 -0
  82. package/lib/todo/store.d.ts +142 -0
  83. package/lib/todo/store.d.ts.map +1 -0
  84. package/lib/todo/store.js +452 -0
  85. package/lib/todo/store.js.map +1 -0
  86. package/lib/types/client/index.d.ts +7 -0
  87. package/lib/types/client/index.d.ts.map +1 -0
  88. package/package.json +63 -0
  89. package/src/client/index.tsx +994 -0
  90. package/src/host/index.ts +591 -0
  91. package/src/host/memory/batch.ts +107 -0
  92. package/src/host/memory/feedback.ts +36 -0
  93. package/src/host/memory/store.ts +650 -0
  94. package/src/host/migration/cli.ts +139 -0
  95. package/src/host/migration/fixture.ts +218 -0
  96. package/src/host/migration/service.ts +786 -0
  97. package/src/host/prompt/snapshot.ts +95 -0
  98. package/src/host/review/queue.ts +325 -0
  99. package/src/host/skills-browser.ts +191 -0
  100. package/src/host/storage/atomic-store.ts +605 -0
  101. package/src/host/storage/layout.ts +191 -0
  102. package/src/host/storage/legacy-format.ts +308 -0
  103. package/src/host/sync/config.ts +67 -0
  104. package/src/host/sync/git.ts +280 -0
  105. package/src/host/sync/index.ts +5 -0
  106. package/src/host/sync/layout.ts +39 -0
  107. package/src/host/sync/merge.ts +188 -0
  108. package/src/host/sync/service.ts +494 -0
  109. package/src/host/todo/store.ts +480 -0
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Atomic file store for Maestro memory — per-directory lock, temp-write/rename,
3
+ * reread validation, backups, duplicate detection.
4
+ *
5
+ * Each mutation:
6
+ * 1. Acquires a same-directory lock file (.maestro.lock) with stale detection
7
+ * (mtime timeout + pid liveness via kill(pid,0)).
8
+ * 2. Validates the on-disk content round-trips through parseEntries/serializeEntries.
9
+ * Non-canonical files are backed up to `<file>.bak.<timestamp>` before the
10
+ * operation is refused (drift guard).
11
+ * 3. Checks for exact duplicates (ID-stripped) so re-adds are no-ops.
12
+ * 4. Writes to a same-directory temp file (`.<uuid>.tmp`, mode 0o600, flag wx),
13
+ * fsyncs the temp, renames atomically over the target, fsyncs the parent
14
+ * directory on POSIX, then rereads and validates the result.
15
+ * 5. Cleans up the temp file on any failure; the previous file is never
16
+ * clobbered on error.
17
+ *
18
+ * Uses only `node:fs` / `node:fs/promises` atomic primitives: open(wx),
19
+ * writeFile, fsync, rename, unlink. No third-party dependencies.
20
+ *
21
+ * @module storage/atomic-store
22
+ */
23
+
24
+ import { createHash, randomUUID } from 'node:crypto'
25
+ import {
26
+ closeSync,
27
+ existsSync,
28
+ fsyncSync,
29
+ mkdirSync,
30
+ openSync,
31
+ readFileSync,
32
+ renameSync,
33
+ rmSync,
34
+ statSync,
35
+ writeFileSync,
36
+ } from 'node:fs'
37
+ import {
38
+ mkdir,
39
+ open,
40
+ readFile,
41
+ rename,
42
+ rm,
43
+ stat,
44
+ writeFile,
45
+ } from 'node:fs/promises'
46
+ import { dirname, join } from 'node:path'
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Delimiter + parse/serialize (pure, no I/O)
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /** Entry delimiter, byte-compatible with Hermes MEMORY.md / USER.md. */
53
+ export const ENTRY_DELIMITER = '\n§\n'
54
+
55
+ /**
56
+ * Split raw file text into trimmed, non-empty entries.
57
+ * @param text - raw file content
58
+ * @returns entries
59
+ */
60
+ export function parseEntries(text: string): string[] {
61
+ return text
62
+ .split(ENTRY_DELIMITER)
63
+ .map((e) => e.trim())
64
+ .filter((e) => e.length > 0)
65
+ }
66
+
67
+ /**
68
+ * Serialize entries into canonical file text.
69
+ * @param entries - entries to serialize
70
+ * @returns canonical file content (entries joined by delimiter + trailing newline)
71
+ */
72
+ export function serializeEntries(entries: string[]): string {
73
+ return entries.length === 0 ? '' : entries.join(ENTRY_DELIMITER) + '\n'
74
+ }
75
+
76
+ /**
77
+ * Whether raw text is the canonical serialization of its own entries.
78
+ * Blank / whitespace-only text counts as canonical (empty store).
79
+ * @param text - raw file content
80
+ * @returns true when round-tripping preserves the text
81
+ */
82
+ export function isCanonical(text: string): boolean {
83
+ return text.trim() === '' || serializeEntries(parseEntries(text)) === text
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Duplicate detection (ID-aware)
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /** Regex for cross-device entry ID prefix (space after colon allowed, case-insensitive). */
91
+ const ENTRY_ID_RE = /^\[id:\s*[0-9a-f]{8}\]\s*/i
92
+
93
+ /**
94
+ * Strip the `[id:xxxxxxxx]` prefix from an entry, if present.
95
+ * @param entry - full entry text
96
+ * @returns text without the ID prefix
97
+ */
98
+ export function stripEntryId(entry: string): string {
99
+ return String(entry).replace(ENTRY_ID_RE, '')
100
+ }
101
+
102
+ /**
103
+ * Check whether `candidate` already exists in `entries`, comparing after
104
+ * stripping IDs so a re-add with a new random ID is still detected as a
105
+ * duplicate (IDs are random per write, direct includes would never hit).
106
+ * @param entries - existing entries
107
+ * @param candidate - new entry text
108
+ * @returns true when a duplicate exists
109
+ */
110
+ export function isDuplicate(entries: string[], candidate: string): boolean {
111
+ const needle = stripEntryId(candidate)
112
+ return entries.some((e) => stripEntryId(e) === needle)
113
+ }
114
+
115
+ /**
116
+ * Find the exact index of `exact` in `entries`, ID-immune (strip-and-compare).
117
+ * Returns -1 when not found or when multiple hits make the match ambiguous.
118
+ * @param entries - existing entries
119
+ * @param exact - full entry text to find
120
+ * @returns index or -1
121
+ */
122
+ export function findExactIndex(entries: string[], exact: string): number {
123
+ const target = stripEntryId(exact)
124
+ let found = -1
125
+ for (let i = 0; i < entries.length; i++) {
126
+ if (stripEntryId(entries[i]) === target) {
127
+ if (found !== -1) return -1 // ambiguous
128
+ found = i
129
+ }
130
+ }
131
+ return found
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Lock (per-directory)
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /** A lock file older than this is considered abandoned (stale). */
139
+ export const STALE_LOCK_MS = 10_000
140
+ /** How long to keep waiting for the lock before failing. */
141
+ export const LOCK_TIMEOUT_MS = 5_000
142
+ /** Spin interval while waiting for the lock. */
143
+ export const LOCK_RETRY_MS = 25
144
+
145
+ /** Lock file name inside each directory. */
146
+ export const LOCK_FILE = '.maestro.lock'
147
+
148
+ /** Directories whose lock this process currently holds (reentrancy guard). */
149
+ const heldLocks = new Set<string>()
150
+
151
+ /** Lock file content: pid + timestamp for stale detection. */
152
+ function lockJson(): string {
153
+ return JSON.stringify({ pid: process.pid, at: Date.now() })
154
+ }
155
+
156
+ /**
157
+ * Determine whether a lock is stale (exported for tests): mtime timeout or
158
+ * the pid inside the lock file is no longer alive (killed / power loss ->
159
+ * stale immediately without waiting for timeout).
160
+ * @param lockPath - lock file path
161
+ * @returns true when stale
162
+ */
163
+ export function isStaleLock(lockPath: string): boolean {
164
+ try {
165
+ const info = statSync(lockPath)
166
+ try {
167
+ const owner = JSON.parse(readFileSync(lockPath, 'utf8'))
168
+ if (typeof owner.pid === 'number') {
169
+ try {
170
+ process.kill(owner.pid, 0)
171
+ return false // owner alive -> valid
172
+ } catch {
173
+ return true // owner dead
174
+ }
175
+ }
176
+ } catch {
177
+ // old-format / unparseable -> fall back to mtime
178
+ }
179
+ return Date.now() - info.mtimeMs > STALE_LOCK_MS
180
+ } catch {
181
+ return false
182
+ }
183
+ }
184
+
185
+ /** Async variant of stale check for the async lock path. */
186
+ export async function isStaleLockAsync(lockPath: string): Promise<boolean> {
187
+ try {
188
+ const info = await stat(lockPath)
189
+ try {
190
+ const owner = JSON.parse(await readFile(lockPath, 'utf8'))
191
+ if (typeof owner.pid === 'number') {
192
+ try {
193
+ process.kill(owner.pid, 0)
194
+ return false
195
+ } catch {
196
+ return true
197
+ }
198
+ }
199
+ } catch {
200
+ // fall back to mtime
201
+ }
202
+ return Date.now() - info.mtimeMs > STALE_LOCK_MS
203
+ } catch {
204
+ return false
205
+ }
206
+ }
207
+
208
+ function sleepSync(ms: number): void {
209
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
210
+ }
211
+
212
+ function sleep(ms: number): Promise<void> {
213
+ return new Promise((resolve) => setTimeout(resolve, ms))
214
+ }
215
+
216
+ /**
217
+ * Acquire the directory lock synchronously, run `fn`, release.
218
+ * Reentrant within this process (outer section remains exclusive vs others).
219
+ * @param dir - directory whose lock to take
220
+ * @param fn - critical section
221
+ * @returns section result
222
+ */
223
+ export function withLockSync<T>(dir: string, fn: () => T): T {
224
+ if (heldLocks.has(dir)) return fn()
225
+ const lockPath = join(dir, LOCK_FILE)
226
+ mkdirSync(dir, { recursive: true })
227
+ const deadline = Date.now() + LOCK_TIMEOUT_MS
228
+ for (;;) {
229
+ let acquired = false
230
+ try {
231
+ const fd = openSync(lockPath, 'wx')
232
+ try {
233
+ writeFileSync(lockPath, lockJson())
234
+ } finally {
235
+ closeSync(fd)
236
+ }
237
+ acquired = true
238
+ } catch (error: unknown) {
239
+ const code = (error as NodeJS.ErrnoException).code
240
+ if (code !== 'EEXIST') throw error
241
+ }
242
+ if (acquired) break
243
+ if (isStaleLock(lockPath)) rmSync(lockPath, { force: true })
244
+ if (Date.now() >= deadline) throw new Error('atomic-store: timed out waiting for directory lock')
245
+ sleepSync(LOCK_RETRY_MS)
246
+ }
247
+ heldLocks.add(dir)
248
+ try {
249
+ return fn()
250
+ } finally {
251
+ heldLocks.delete(dir)
252
+ rmSync(lockPath, { force: true })
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Acquire the directory lock asynchronously, run `fn`, release.
258
+ * Reentrant within this process.
259
+ * @param dir - directory whose lock to take
260
+ * @param fn - critical section (may be async)
261
+ * @returns section result
262
+ */
263
+ export async function withLock<T>(dir: string, fn: () => T | Promise<T>): Promise<T> {
264
+ if (heldLocks.has(dir)) return await fn()
265
+ const lockPath = join(dir, LOCK_FILE)
266
+ await mkdir(dir, { recursive: true })
267
+ const deadline = Date.now() + LOCK_TIMEOUT_MS
268
+ for (;;) {
269
+ let acquired = false
270
+ try {
271
+ const handle = await open(lockPath, 'wx', 0o600)
272
+ try {
273
+ await handle.writeFile(lockJson(), 'utf8')
274
+ } finally {
275
+ await handle.close()
276
+ }
277
+ acquired = true
278
+ } catch (error: unknown) {
279
+ const code = (error as NodeJS.ErrnoException).code
280
+ if (code !== 'EEXIST') throw error
281
+ }
282
+ if (acquired) break
283
+ if (await isStaleLockAsync(lockPath)) await rm(lockPath, { force: true })
284
+ if (Date.now() >= deadline) throw new Error('atomic-store: timed out waiting for directory lock')
285
+ await sleep(LOCK_RETRY_MS)
286
+ }
287
+ heldLocks.add(dir)
288
+ try {
289
+ return await fn()
290
+ } finally {
291
+ heldLocks.delete(dir)
292
+ await rm(lockPath, { force: true })
293
+ }
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Backups
298
+ // ---------------------------------------------------------------------------
299
+
300
+ /**
301
+ * Create a backup of `filePath` at `<file>.bak.<timestamp>`.
302
+ * Returns the backup path, or null when the source does not exist.
303
+ * @param filePath - file to back up
304
+ * @returns backup path or null
305
+ */
306
+ export function createBackupSync(filePath: string): string | null {
307
+ if (!existsSync(filePath)) return null
308
+ const backupPath = `${filePath}.bak.${Date.now()}`
309
+ const data = readFileSync(filePath)
310
+ writeFileSync(backupPath, data)
311
+ return backupPath
312
+ }
313
+
314
+ /**
315
+ * Async backup.
316
+ * @param filePath - file to back up
317
+ * @returns backup path or null
318
+ */
319
+ export async function createBackup(filePath: string): Promise<string | null> {
320
+ try {
321
+ const data = await readFile(filePath)
322
+ const backupPath = `${filePath}.bak.${Date.now()}`
323
+ // Create backup atomically; if it exists, bump timestamp
324
+ await writeFile(backupPath, data, { flag: 'wx', mode: 0o600 })
325
+ return backupPath
326
+ } catch (error: unknown) {
327
+ const code = (error as NodeJS.ErrnoException).code
328
+ if (code === 'ENOENT') return null
329
+ throw error
330
+ }
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Atomic write: temp-write / rename / reread validation
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /** fsync a POSIX directory so a just-renamed entry is crash-durable. */
338
+ /* v8 ignore start -- Windows rejects O_RDONLY directory opens */
339
+ async function fsyncDirectory(dir: string): Promise<void> {
340
+ if (process.platform === 'win32') return
341
+ const handle = await open(dir, 'r')
342
+ try {
343
+ await handle.sync()
344
+ } finally {
345
+ await handle.close()
346
+ }
347
+ }
348
+
349
+ function fsyncDirectorySync(dir: string): void {
350
+ if (process.platform === 'win32') return
351
+ const fd = openSync(dir, 'r')
352
+ try {
353
+ fsyncSync(fd)
354
+ } finally {
355
+ closeSync(fd)
356
+ }
357
+ }
358
+ /* v8 ignore stop */
359
+
360
+ /**
361
+ * Durably replace `filePath` with `content` via same-directory temp file,
362
+ * fsync, atomic rename, directory fsync, and reread validation.
363
+ * @param filePath - absolute target file path
364
+ * @param content - full new file content
365
+ */
366
+ export async function writeAtomic(filePath: string, content: string): Promise<void> {
367
+ const dir = dirname(filePath)
368
+ await mkdir(dir, { recursive: true })
369
+ const tmp = join(dir, `.${randomUUID()}.tmp`)
370
+ try {
371
+ const handle = await open(tmp, 'wx', 0o600)
372
+ try {
373
+ await handle.writeFile(content, 'utf8')
374
+ await handle.sync()
375
+ } finally {
376
+ await handle.close()
377
+ }
378
+ await rename(tmp, filePath)
379
+ await fsyncDirectory(dir)
380
+ // Reread validation: ensure the file now contains exactly what we wrote.
381
+ const reread = await readFile(filePath, 'utf8')
382
+ if (reread !== content) {
383
+ throw new Error(`atomic-store: reread validation failed for ${filePath} (expected ${content.length} chars, got ${reread.length})`)
384
+ }
385
+ } catch (error) {
386
+ await rm(tmp, { force: true })
387
+ throw error
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Synchronous atomic write (same guarantees, blocking).
393
+ * @param filePath - absolute target file path
394
+ * @param content - full new file content
395
+ */
396
+ export function writeAtomicSync(filePath: string, content: string): void {
397
+ const dir = dirname(filePath)
398
+ mkdirSync(dir, { recursive: true })
399
+ const tmp = join(dir, `.${randomUUID()}.tmp`)
400
+ try {
401
+ const fd = openSync(tmp, 'wx', 0o600)
402
+ try {
403
+ writeFileSync(tmp, content, 'utf8')
404
+ fsyncSync(fd)
405
+ } finally {
406
+ closeSync(fd)
407
+ }
408
+ renameSync(tmp, filePath)
409
+ fsyncDirectorySync(dir)
410
+ // Reread validation
411
+ const reread = readFileSync(filePath, 'utf8')
412
+ if (reread !== content) {
413
+ throw new Error(`atomic-store: reread validation failed for ${filePath}`)
414
+ }
415
+ } catch (error) {
416
+ rmSync(tmp, { force: true })
417
+ throw error
418
+ }
419
+ }
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // High-level entry helpers (lock + backup + duplicate detection + atomic write)
423
+ // ---------------------------------------------------------------------------
424
+
425
+ /**
426
+ * Read entries from `filePath`. Missing file yields [].
427
+ * @param filePath - file to read
428
+ * @returns parsed entries
429
+ */
430
+ export function readEntriesSync(filePath: string): string[] {
431
+ try {
432
+ return parseEntries(readFileSync(filePath, 'utf8'))
433
+ } catch (error: unknown) {
434
+ const code = (error as NodeJS.ErrnoException).code
435
+ if (code === 'ENOENT') return []
436
+ throw error
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Async read entries.
442
+ * @param filePath - file to read
443
+ * @returns parsed entries
444
+ */
445
+ export async function readEntries(filePath: string): Promise<string[]> {
446
+ try {
447
+ return parseEntries(await readFile(filePath, 'utf8'))
448
+ } catch (error: unknown) {
449
+ const code = (error as NodeJS.ErrnoException).code
450
+ if (code === 'ENOENT') return []
451
+ throw error
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Append `entry` to `filePath` atomically, with duplicate detection and
457
+ * drift-guard backup.
458
+ *
459
+ * - Acquires per-directory lock.
460
+ * - Validates existing content is canonical; if not, backs up and throws.
461
+ * - Checks for duplicate (ID-stripped); returns `{duplicate:true}` without writing.
462
+ * - Writes via temp+rename+reread.
463
+ *
464
+ * @param filePath - target file
465
+ * @param entry - entry to append (already trimmed)
466
+ * @returns result
467
+ */
468
+ export async function appendEntryAtomic(
469
+ filePath: string,
470
+ entry: string,
471
+ ): Promise<{ ok: true; duplicate?: boolean; entries: string[] } | { ok: false; error: string; backup?: string }> {
472
+ const dir = dirname(filePath)
473
+ return withLock(dir, async () => {
474
+ // Drift guard: read raw and validate
475
+ let raw = ''
476
+ try {
477
+ raw = await readFile(filePath, 'utf8')
478
+ } catch (error: unknown) {
479
+ const code = (error as NodeJS.ErrnoException).code
480
+ if (code !== 'ENOENT') throw error
481
+ raw = ''
482
+ }
483
+ if (raw !== '' && !isCanonical(raw)) {
484
+ const backup = await createBackup(filePath)
485
+ return { ok: false as const, error: `drift: non-canonical content backed up to ${backup}`, backup: backup ?? undefined }
486
+ }
487
+ const entries = parseEntries(raw)
488
+ if (isDuplicate(entries, entry)) {
489
+ return { ok: true as const, duplicate: true, entries }
490
+ }
491
+ const next = [...entries, entry]
492
+ const content = serializeEntries(next)
493
+ await writeAtomic(filePath, content)
494
+ return { ok: true as const, entries: next }
495
+ })
496
+ }
497
+
498
+ /**
499
+ * Synchronous variant of appendEntryAtomic.
500
+ * @param filePath - target file
501
+ * @param entry - entry to append
502
+ * @returns result
503
+ */
504
+ export function appendEntryAtomicSync(
505
+ filePath: string,
506
+ entry: string,
507
+ ): { ok: true; duplicate?: boolean; entries: string[] } | { ok: false; error: string; backup?: string } {
508
+ const dir = dirname(filePath)
509
+ return withLockSync(dir, () => {
510
+ let raw = ''
511
+ try {
512
+ raw = readFileSync(filePath, 'utf8')
513
+ } catch (error: unknown) {
514
+ const code = (error as NodeJS.ErrnoException).code
515
+ if (code !== 'ENOENT') throw error
516
+ raw = ''
517
+ }
518
+ if (raw !== '' && !isCanonical(raw)) {
519
+ const backup = createBackupSync(filePath)
520
+ return { ok: false, error: `drift: non-canonical content backed up to ${backup}`, backup: backup ?? undefined }
521
+ }
522
+ const entries = parseEntries(raw)
523
+ if (isDuplicate(entries, entry)) {
524
+ return { ok: true, duplicate: true, entries }
525
+ }
526
+ const next = [...entries, entry]
527
+ const content = serializeEntries(next)
528
+ writeAtomicSync(filePath, content)
529
+ return { ok: true, entries: next }
530
+ })
531
+ }
532
+
533
+ /**
534
+ * Replace or remove via atomic write with drift guard and backup.
535
+ * Caller provides the next entries array; this validates, backs up on drift, and writes.
536
+ * @param filePath - target file
537
+ * @param nextEntries - complete new entries array
538
+ * @returns result
539
+ */
540
+ export async function writeEntriesAtomic(
541
+ filePath: string,
542
+ nextEntries: string[],
543
+ ): Promise<{ ok: true; entries: string[] } | { ok: false; error: string; backup?: string }> {
544
+ const dir = dirname(filePath)
545
+ return withLock(dir, async () => {
546
+ let raw = ''
547
+ try {
548
+ raw = await readFile(filePath, 'utf8')
549
+ } catch (error: unknown) {
550
+ const code = (error as NodeJS.ErrnoException).code
551
+ if (code !== 'ENOENT') throw error
552
+ raw = ''
553
+ }
554
+ if (raw !== '' && !isCanonical(raw)) {
555
+ const backup = await createBackup(filePath)
556
+ return { ok: false, error: `drift: non-canonical content backed up to ${backup}`, backup: backup ?? undefined }
557
+ }
558
+ const content = serializeEntries(nextEntries)
559
+ await writeAtomic(filePath, content)
560
+ return { ok: true, entries: nextEntries }
561
+ })
562
+ }
563
+
564
+ /**
565
+ * Synchronous variant.
566
+ * @param filePath - target file
567
+ * @param nextEntries - complete new entries array
568
+ * @returns result
569
+ */
570
+ export function writeEntriesAtomicSync(
571
+ filePath: string,
572
+ nextEntries: string[],
573
+ ): { ok: true; entries: string[] } | { ok: false; error: string; backup?: string } {
574
+ const dir = dirname(filePath)
575
+ return withLockSync(dir, () => {
576
+ let raw = ''
577
+ try {
578
+ raw = readFileSync(filePath, 'utf8')
579
+ } catch (error: unknown) {
580
+ const code = (error as NodeJS.ErrnoException).code
581
+ if (code !== 'ENOENT') throw error
582
+ raw = ''
583
+ }
584
+ if (raw !== '' && !isCanonical(raw)) {
585
+ const backup = createBackupSync(filePath)
586
+ return { ok: false, error: `drift: non-canonical content backed up to ${backup}`, backup: backup ?? undefined }
587
+ }
588
+ const content = serializeEntries(nextEntries)
589
+ writeAtomicSync(filePath, content)
590
+ return { ok: true, entries: nextEntries }
591
+ })
592
+ }
593
+
594
+ // ---------------------------------------------------------------------------
595
+ // Utility: project hash (re-export for layout consumers)
596
+ // ---------------------------------------------------------------------------
597
+
598
+ /**
599
+ * Stable 12-hex project hash for a cwd.
600
+ * @param cwd - working directory
601
+ * @returns 12-char hash
602
+ */
603
+ export function projectHash(cwd: string): string {
604
+ return createHash('sha1').update(cwd).digest('hex').slice(0, 12)
605
+ }