openhermes 4.9.2 → 4.11.2

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 (69) hide show
  1. package/CONTEXT.md +1 -1
  2. package/README.md +32 -31
  3. package/bootstrap.ts +262 -45
  4. package/harness/agents/oh-planner.md +1 -1
  5. package/harness/agents/openhermes.md +27 -126
  6. package/harness/codex/AUTOPILOT.md +99 -3
  7. package/harness/codex/CHARTER.md +3 -4
  8. package/harness/lib/background/background.test.ts +197 -0
  9. package/harness/lib/background/index.ts +7 -0
  10. package/harness/lib/background/interfaces.ts +31 -0
  11. package/harness/lib/background/manager.ts +320 -0
  12. package/harness/lib/composer/compose.test.ts +168 -0
  13. package/harness/lib/composer/compose.ts +65 -0
  14. package/harness/lib/composer/fragments/01-identity.md +1 -0
  15. package/harness/lib/composer/fragments/02-delegation.md +6 -0
  16. package/harness/lib/composer/fragments/03-permissions.md +13 -0
  17. package/harness/lib/composer/fragments/04-task-flow.md +15 -0
  18. package/harness/lib/composer/fragments/05-confidence.md +5 -0
  19. package/harness/lib/composer/fragments/06-parallelization.md +17 -0
  20. package/harness/lib/composer/fragments/07-shell.md +41 -0
  21. package/harness/lib/composer/fragments/08-routing.md +8 -0
  22. package/harness/lib/composer/fragments/09-guardrails.md +12 -0
  23. package/harness/lib/composer/index.ts +1 -0
  24. package/harness/lib/hooks/builtins/confidence-gate-hook.ts +70 -0
  25. package/harness/lib/hooks/builtins/delegation-depth-hook.ts +59 -0
  26. package/harness/lib/hooks/builtins/error-recovery-hook.ts +107 -0
  27. package/harness/lib/hooks/builtins/memory-sync-hook.ts +73 -0
  28. package/harness/lib/hooks/builtins/plan-check-hook.ts +43 -0
  29. package/harness/lib/hooks/builtins/route-tracking-hook.ts +147 -0
  30. package/harness/lib/hooks/builtins/sanity-check-hook.ts +52 -0
  31. package/harness/lib/hooks/builtins/shell-detect-hook.ts +96 -0
  32. package/harness/lib/hooks/hooks.test.ts +1016 -0
  33. package/harness/lib/hooks/index.ts +30 -0
  34. package/harness/lib/hooks/registry.ts +416 -0
  35. package/harness/lib/hooks/types.ts +71 -0
  36. package/harness/lib/memory/index.ts +18 -0
  37. package/harness/lib/memory/interfaces.ts +53 -0
  38. package/harness/lib/memory/memory-manager.ts +205 -0
  39. package/harness/lib/memory/memory.test.ts +491 -0
  40. package/harness/lib/memory/plan-store.ts +366 -0
  41. package/harness/lib/recovery/handler.ts +243 -0
  42. package/harness/lib/recovery/index.ts +14 -0
  43. package/harness/lib/recovery/interfaces.ts +48 -0
  44. package/harness/lib/recovery/patterns.ts +149 -0
  45. package/harness/lib/recovery/recovery.test.ts +312 -0
  46. package/harness/lib/sanity/anomaly-tracker.ts +127 -0
  47. package/harness/lib/sanity/checker.ts +178 -0
  48. package/harness/lib/sanity/index.ts +13 -0
  49. package/harness/lib/sanity/interfaces.ts +24 -0
  50. package/harness/lib/sanity/sanity.test.ts +472 -0
  51. package/harness/lib/sync/file-watcher.ts +174 -0
  52. package/harness/lib/sync/index.ts +11 -0
  53. package/harness/lib/sync/interfaces.ts +27 -0
  54. package/harness/lib/sync/plan-sync.ts +536 -0
  55. package/harness/lib/sync/sync.test.ts +832 -0
  56. package/harness/skills/oh-init/DEEP.md +2 -2
  57. package/harness/skills/oh-manifest/SKILL.md +1 -1
  58. package/harness/skills/oh-plan-review/DEEP.md +1 -1
  59. package/harness/skills/oh-planner/DEEP.md +3 -3
  60. package/harness/skills/oh-ship/SKILL.md +1 -1
  61. package/harness/skills/oh-skill-craft/SKILL.md +1 -4
  62. package/package.json +5 -5
  63. package/tsconfig.json +1 -1
  64. package/harness/commands/oh-doctor.md +0 -205
  65. package/harness/commands/oh-log.md +0 -18
  66. package/harness/skills/oh-learn/DEEP.md +0 -44
  67. package/harness/skills/oh-learn/SKILL.md +0 -30
  68. package/scripts/count-tokens.mjs +0 -158
  69. package/scripts/oh-doctor.ps1 +0 -342
@@ -0,0 +1,536 @@
1
+ // PlanSync — singleton for MVCC-style concurrent-safe plan file access.
2
+ // Uses file-based version counters, atomic writes, and optimistic retry.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import type { SyncPlanEntry, PlanSyncState, SyncConflict, ConflictStrategy } from "./interfaces.ts";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Constants
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const MAX_RETRIES = 30;
13
+ const BASE_RETRY_DELAY_MS = 25;
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Sleep for ms — used between optimistic retry attempts. */
20
+ function sleep(ms: number): Promise<void> {
21
+ return new Promise((resolve) => setTimeout(resolve, ms));
22
+ }
23
+
24
+ /**
25
+ * Normalize line endings to \n so all regexes work consistently.
26
+ * `\r\n` → `\n`, stray `\r` → `\n`.
27
+ */
28
+ function normalizeEol(text: string): string {
29
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // PlanSync
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export class PlanSync {
37
+ private static instance: PlanSync;
38
+
39
+ private constructor() {}
40
+
41
+ /** Get the singleton instance. */
42
+ static getInstance(): PlanSync {
43
+ if (!PlanSync.instance) {
44
+ PlanSync.instance = new PlanSync();
45
+ }
46
+ return PlanSync.instance;
47
+ }
48
+
49
+ /** Reset singleton — used in tests to get a clean slate. */
50
+ static resetInstance(): void {
51
+ PlanSync.instance = null as unknown as PlanSync;
52
+ }
53
+
54
+ // -----------------------------------------------------------------------
55
+ // Public API
56
+ // -----------------------------------------------------------------------
57
+
58
+ /**
59
+ * Read a plan markdown file and parse it into structured sync state.
60
+ *
61
+ * @param planFilePath — path to the plan `.md` file.
62
+ */
63
+ async readPlanState(planFilePath: string): Promise<PlanSyncState> {
64
+ let content: string;
65
+ try {
66
+ content = await fs.promises.readFile(planFilePath, "utf8");
67
+ } catch {
68
+ return {
69
+ entries: new Map(),
70
+ version: 0,
71
+ lastWriter: "system",
72
+ lastWriteTime: Date.now(),
73
+ };
74
+ }
75
+ return this.parsePlanContent(content);
76
+ }
77
+
78
+ /**
79
+ * Atomically write the full sync state back to the plan file.
80
+ * Uses temp file + rename to prevent partial writes.
81
+ */
82
+ async writePlanState(planFilePath: string, state: PlanSyncState): Promise<void> {
83
+ const existing = await this.readPlanRaw(planFilePath).catch(() => "");
84
+ const content = this.serializePlanContent(existing, state);
85
+ await this.atomicWrite(planFilePath, content);
86
+ }
87
+
88
+ /**
89
+ * Update a single entry using optimistic concurrency:
90
+ * 1. Read current on-disk state
91
+ * 2. Increment version counters
92
+ * 3. Atomic write
93
+ * 4. Re-read and verify no conflict; retry if needed
94
+ */
95
+ async updateEntry(planFilePath: string, entry: SyncPlanEntry): Promise<void> {
96
+ // Add a small random initial delay to spread out concurrent callers
97
+ await sleep(Math.random() * 10);
98
+
99
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
100
+ const currentState = await this.readPlanState(planFilePath);
101
+ const existing = currentState.entries.get(entry.id);
102
+
103
+ // Snapshot all entry versions before any writes (cross-entry conflict detection)
104
+ const preWriteVersions = new Map<string, number>();
105
+ for (const [id, e] of currentState.entries) {
106
+ preWriteVersions.set(id, e.version);
107
+ }
108
+
109
+ // Merge incoming fields with existing, bump version
110
+ const merged: SyncPlanEntry = {
111
+ ...existing,
112
+ ...entry,
113
+ version: (existing?.version ?? 0) + 1,
114
+ timestamp: Date.now(),
115
+ };
116
+
117
+ currentState.entries.set(entry.id, merged);
118
+ currentState.version = (currentState.version ?? 0) + 1;
119
+ currentState.lastWriter = entry.agent ?? "unknown";
120
+ currentState.lastWriteTime = Date.now();
121
+
122
+ await this.writePlanState(planFilePath, currentState);
123
+
124
+ // Re-read and verify ALL pre-existing entries have the expected version.
125
+ // For entries we didn't write, expected = pre-write version (unchanged).
126
+ // For our own entry, expected = pre-write version + 1 (intentionally bumped).
127
+ // This catches concurrent writers who overwrote any entry (including our own)
128
+ // between our write and verification.
129
+ const expectedVersions = new Map(preWriteVersions);
130
+ expectedVersions.set(entry.id, merged.version);
131
+
132
+ const verifiedState = await this.readPlanState(planFilePath);
133
+ let allConsistent = true;
134
+
135
+ for (const [id, expectedVersion] of expectedVersions) {
136
+ const current = verifiedState.entries.get(id);
137
+ if (!current || current.version !== expectedVersion) {
138
+ allConsistent = false;
139
+ break;
140
+ }
141
+ }
142
+
143
+ // Also check no unexpected new entries appeared (concurrent writer
144
+ // adding an entry we don't know about would lose their data on write).
145
+ if (allConsistent) {
146
+ for (const [id] of verifiedState.entries) {
147
+ if (!preWriteVersions.has(id) && id !== entry.id) {
148
+ allConsistent = false;
149
+ break;
150
+ }
151
+ }
152
+ }
153
+
154
+ if (allConsistent) return;
155
+
156
+ // Version mismatch — retry with fresh state
157
+ // Add jitter so concurrent writers don't stay synchronized
158
+ const jitter = Math.random() * BASE_RETRY_DELAY_MS;
159
+ await sleep(BASE_RETRY_DELAY_MS + jitter);
160
+ }
161
+
162
+ throw new Error(
163
+ `[PlanSync] Exceeded ${MAX_RETRIES} retries updating entry "${entry.id}" in "${planFilePath}"`,
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Compare a local (in-memory) state against a remote (on-disk) state and
169
+ * return any version mismatches.
170
+ */
171
+ detectConflicts(localState: PlanSyncState, remoteState: PlanSyncState): SyncConflict[] {
172
+ const conflicts: SyncConflict[] = [];
173
+ const allIds = new Set([
174
+ ...localState.entries.keys(),
175
+ ...remoteState.entries.keys(),
176
+ ]);
177
+
178
+ for (const entryId of allIds) {
179
+ const local = localState.entries.get(entryId);
180
+ const remote = remoteState.entries.get(entryId);
181
+
182
+ if (!remote) continue; // local-only — no conflict
183
+ if (!local) {
184
+ // remote has it, local doesn't
185
+ conflicts.push({
186
+ entryId,
187
+ localVersion: 0,
188
+ remoteVersion: remote.version,
189
+ localStatus: "(missing)",
190
+ remoteStatus: remote.status,
191
+ });
192
+ continue;
193
+ }
194
+
195
+ if (local.version !== remote.version) {
196
+ conflicts.push({
197
+ entryId,
198
+ localVersion: local.version,
199
+ remoteVersion: remote.version,
200
+ localStatus: local.status,
201
+ remoteStatus: remote.status,
202
+ });
203
+ }
204
+ }
205
+
206
+ return conflicts;
207
+ }
208
+
209
+ /**
210
+ * Resolve a list of conflicts using the given strategy.
211
+ *
212
+ * - `last-writer-wins`: returns empty array — caller should re-read from disk
213
+ * - `manual`: throws with full conflict details
214
+ */
215
+ resolveConflicts(
216
+ conflicts: SyncConflict[],
217
+ strategy: ConflictStrategy = "last-writer-wins",
218
+ ): SyncPlanEntry[] {
219
+ if (strategy === "manual") {
220
+ const details = conflicts
221
+ .map(
222
+ (c) =>
223
+ ` - "${c.entryId}": local v${c.localVersion} (${c.localStatus}) vs remote v${c.remoteVersion} (${c.remoteStatus})`,
224
+ )
225
+ .join("\n");
226
+ throw new Error(
227
+ `[PlanSync] Manual conflict resolution required — ${conflicts.length} conflict(s):\n${details}`,
228
+ );
229
+ }
230
+
231
+ // last-writer-wins: return empty — caller should re-read from disk
232
+ return [];
233
+ }
234
+
235
+ /**
236
+ * Raw read of plan file text — used by the markdown parser.
237
+ */
238
+ async readPlanRaw(filePath: string): Promise<string> {
239
+ return fs.promises.readFile(filePath, "utf8");
240
+ }
241
+
242
+ // -----------------------------------------------------------------------
243
+ // Internals — parsing
244
+ // -----------------------------------------------------------------------
245
+
246
+ /**
247
+ * Parse raw markdown content into a PlanSyncState.
248
+ *
249
+ * Strategy:
250
+ * 1. Read metadata (before first `##` heading) for version counters + entry versions.
251
+ * 2. Find `## Tasks` section and extract tasks from `### Task N: Title` headings.
252
+ * 3. For each task, determine status from checkboxes / active-task / completed sections.
253
+ */
254
+ private parsePlanContent(content: string): PlanSyncState {
255
+ const normalized = normalizeEol(content);
256
+ const entries = new Map<string, SyncPlanEntry>();
257
+ const state: PlanSyncState = {
258
+ entries,
259
+ version: 1,
260
+ lastWriter: "unknown",
261
+ lastWriteTime: Date.now(),
262
+ };
263
+
264
+ // ---- 1. Extract metadata (everything above the first ## heading) ----
265
+ const metaEnd = normalized.search(/\n## /);
266
+ const metaBlock = metaEnd >= 0 ? normalized.slice(0, metaEnd) : normalized;
267
+ const metaLines = metaBlock.split("\n");
268
+
269
+ for (const line of metaLines) {
270
+ const kv = line.match(/^(Sync-Version|Last-Writer|Last-Write-Time):\s*(.+)$/);
271
+ if (!kv) continue;
272
+
273
+ switch (kv[1]) {
274
+ case "Sync-Version":
275
+ state.version = parseInt(kv[2], 10) || 1;
276
+ break;
277
+ case "Last-Writer":
278
+ state.lastWriter = kv[2].trim();
279
+ break;
280
+ case "Last-Write-Time":
281
+ state.lastWriteTime = parseInt(kv[2], 10) || Date.now();
282
+ break;
283
+ }
284
+ }
285
+
286
+ // Parse per-entry metadata (version, description, status)
287
+ const entryMeta = new Map<string, Record<string, string>>();
288
+ for (const line of metaLines) {
289
+ const ev = line.match(/^entry-(task-\d+)-version:\s*(\d+)$/i);
290
+ if (ev) {
291
+ const m = entryMeta.get(ev[1]) ?? {};
292
+ m.version = ev[2];
293
+ entryMeta.set(ev[1], m);
294
+ }
295
+ const ed = line.match(/^entry-(task-\d+)-description:\s*(.+)$/i);
296
+ if (ed) {
297
+ const m = entryMeta.get(ed[1]) ?? {};
298
+ m.description = ed[2].trim();
299
+ entryMeta.set(ed[1], m);
300
+ }
301
+ const es = line.match(/^entry-(task-\d+)-status:\s*(.+)$/i);
302
+ if (es) {
303
+ const m = entryMeta.get(es[1]) ?? {};
304
+ m.status = es[2].trim().toLowerCase();
305
+ entryMeta.set(es[1], m);
306
+ }
307
+ }
308
+
309
+ // ---- 2. Find relevant sections ----
310
+ const sections = this.splitIntoSections(normalized);
311
+ const tasksContent = sections.get("## Tasks") ?? null;
312
+ const completedContent = sections.get("## Completed") ?? null;
313
+ const activeContent = sections.get("## Active Task") ?? null;
314
+
315
+ // ---- 3. Parse tasks from ## Tasks section ----
316
+ if (tasksContent) {
317
+ const taskBlocks = this.splitTaskBlocks(tasksContent);
318
+
319
+ for (const block of taskBlocks) {
320
+ const hd = block.match(/^###\s+Task\s+(\d+)\s*:\s*(.+?)$/m);
321
+ if (!hd) continue;
322
+
323
+ const taskNum = hd[1];
324
+ const title = hd[2].trim();
325
+ const id = `task-${taskNum}`;
326
+
327
+ // Prefer metadata-stored values over heuristic parsing
328
+ const meta = entryMeta.get(id);
329
+ const heuristicStatus = this.determineTaskStatus(
330
+ block,
331
+ taskNum,
332
+ completedContent,
333
+ activeContent,
334
+ );
335
+
336
+ const entry: SyncPlanEntry = {
337
+ id,
338
+ description: meta?.description ?? title,
339
+ status: (meta?.status as SyncPlanEntry["status"]) ?? heuristicStatus,
340
+ timestamp: Date.now(),
341
+ version: meta?.version ? parseInt(meta.version, 10) : 1,
342
+ };
343
+
344
+ entries.set(id, entry);
345
+ }
346
+ }
347
+
348
+ return state;
349
+ }
350
+
351
+ /**
352
+ * Split markdown into sections keyed by heading (e.g. `## Tasks`).
353
+ * Returns a Map<heading, content>.
354
+ */
355
+ private splitIntoSections(content: string): Map<string, string> {
356
+ const sections = new Map<string, string>();
357
+
358
+ // Split on lines that start with `## ` (level-2 headings)
359
+ const parts = content.split(/\n(?=## )/);
360
+
361
+ for (const part of parts) {
362
+ const hd = part.match(/^(## [^\n]+)/);
363
+ if (!hd) continue;
364
+ const heading = hd[1].trim();
365
+ const body = part.slice(hd[1].length).trim();
366
+ sections.set(heading, body);
367
+ }
368
+
369
+ return sections;
370
+ }
371
+
372
+ /**
373
+ * Split the content of `## Tasks` into individual task blocks,
374
+ * each starting with `### Task N:`.
375
+ */
376
+ private splitTaskBlocks(tasksContent: string): string[] {
377
+ // Split on lines starting with `### `
378
+ const parts = tasksContent.split(/\n(?=### )/);
379
+ return parts.filter((p) => /^###\s+Task\s+\d+\s*:/m.test(p));
380
+ }
381
+
382
+ /**
383
+ * Determine the status of a task based on:
384
+ * - Completed section listing
385
+ * - Active Task section
386
+ * - Success criteria checkboxes within the task block
387
+ * - Blocked/cancelled keywords
388
+ */
389
+ private determineTaskStatus(
390
+ taskBlock: string,
391
+ taskNum: string,
392
+ completedContent: string | null,
393
+ activeContent: string | null,
394
+ ): SyncPlanEntry["status"] {
395
+ // Check Completed section
396
+ if (completedContent) {
397
+ const re = new RegExp(`Task\\s+${taskNum}\\s*:`, "i");
398
+ if (re.test(completedContent)) return "completed";
399
+ }
400
+
401
+ // Check Active Task section
402
+ if (activeContent) {
403
+ const re = new RegExp(`Task\\s+${taskNum}\\s*:`, "i");
404
+ if (re.test(activeContent)) return "in_progress";
405
+ }
406
+
407
+ // Check success-criteria checkboxes
408
+ const checkboxes: string[] = [];
409
+ const cbRe = /^\s*-\s*\[([ xX])\]\s*/gm;
410
+ let m: RegExpExecArray | null;
411
+ while ((m = cbRe.exec(taskBlock)) !== null) {
412
+ checkboxes.push(m[1].toLowerCase());
413
+ }
414
+
415
+ if (checkboxes.length > 0) {
416
+ const allChecked = checkboxes.every((c) => c === "x");
417
+ const anyChecked = checkboxes.some((c) => c === "x");
418
+ if (allChecked) return "completed";
419
+ if (anyChecked) return "in_progress";
420
+ return "pending";
421
+ }
422
+
423
+ // Heuristic keywords
424
+ if (/blocked/i.test(taskBlock) && !/unblocked/i.test(taskBlock)) return "blocked";
425
+ if (/cancelled|canceled/i.test(taskBlock)) return "cancelled";
426
+
427
+ return "pending";
428
+ }
429
+
430
+ // -----------------------------------------------------------------------
431
+ // Internals — serialization
432
+ // -----------------------------------------------------------------------
433
+
434
+ /**
435
+ * Serialize sync state back into plan file content.
436
+ *
437
+ * Replaces / inserts metadata lines (Sync-Version, Last-Writer, etc.)
438
+ * into the header block (before first `##` heading).
439
+ * Preserves all other content as-is.
440
+ */
441
+ private serializePlanContent(existing: string, state: PlanSyncState): string {
442
+ const normalized = normalizeEol(existing);
443
+
444
+ // ---- 1. Build the set of version metadata lines ----
445
+ const metaToWrite = new Map<string, string>();
446
+ metaToWrite.set("Sync-Version", String(state.version));
447
+ metaToWrite.set("Last-Writer", state.lastWriter);
448
+ metaToWrite.set("Last-Write-Time", String(state.lastWriteTime));
449
+
450
+ for (const [, entry] of state.entries) {
451
+ metaToWrite.set(`entry-${entry.id}-version`, String(entry.version));
452
+ metaToWrite.set(`entry-${entry.id}-description`, entry.description);
453
+ metaToWrite.set(`entry-${entry.id}-status`, entry.status);
454
+ }
455
+
456
+ // ---- 2. Find the metadata region (before first ## heading) ----
457
+ const metaEnd = normalized.search(/\n## /);
458
+ const header = metaEnd >= 0 ? normalized.slice(0, metaEnd) : normalized;
459
+ const rest = metaEnd >= 0 ? normalized.slice(metaEnd) : "";
460
+
461
+ // ---- 3. Rebuild the header with updated metadata ----
462
+ const headerLines = header.split("\n");
463
+ const seen = new Set<string>();
464
+ const rebuiltHeader: string[] = [];
465
+
466
+ for (const line of headerLines) {
467
+ const kv = line.match(/^(Sync-Version|Last-Writer|Last-Write-Time|entry-[\w-]+-(?:version|description|status)):/i);
468
+ if (kv) {
469
+ const key = this.normalizeMetaKey(kv[1]);
470
+ const val = metaToWrite.get(key);
471
+ if (val !== undefined) {
472
+ rebuiltHeader.push(`${key}: ${val}`);
473
+ seen.add(key);
474
+ }
475
+ // else: stale metadata line (entry no longer in state) — silently drop
476
+ } else {
477
+ rebuiltHeader.push(line);
478
+ }
479
+ }
480
+
481
+ // Append any metadata keys that weren't in the original header
482
+ for (const [key, val] of metaToWrite) {
483
+ if (!seen.has(key)) {
484
+ rebuiltHeader.push(`${key}: ${val}`);
485
+ }
486
+ }
487
+
488
+ return [...rebuiltHeader, rest].join("\n");
489
+ }
490
+
491
+ /**
492
+ * Normalize a metadata key to its canonical form.
493
+ * Handles case-insensitive matching from the regex above.
494
+ */
495
+ private normalizeMetaKey(key: string): string {
496
+ const lower = key.toLowerCase();
497
+ if (lower === "sync-version") return "Sync-Version";
498
+ if (lower === "last-writer") return "Last-Writer";
499
+ if (lower === "last-write-time") return "Last-Write-Time";
500
+ // entry-*-version — preserve as-is from the map (already canonical)
501
+ return key;
502
+ }
503
+
504
+ // -----------------------------------------------------------------------
505
+ // Internals — atomic write
506
+ // -----------------------------------------------------------------------
507
+
508
+ /**
509
+ * Atomic file write: write to a temp file in the same directory, then rename.
510
+ * Rename is atomic on the same filesystem. On Windows, rename can EPERM
511
+ * under cross-device or locking scenarios — fallback writes directly to target.
512
+ */
513
+ private async atomicWrite(filePath: string, content: string): Promise<void> {
514
+ const dir = path.dirname(filePath);
515
+ const base = path.basename(filePath);
516
+ // Unique suffix per write to avoid temp-file races between concurrent writers
517
+ const suffix = `${process.pid}_${Date.now()}`;
518
+ const tmpPath = path.join(dir, `.${base}.${suffix}.tmp`);
519
+
520
+ await fs.promises.writeFile(tmpPath, content, "utf8");
521
+
522
+ // Strategy:
523
+ // 1. Try rename (atomic on same filesystem; Windows can overwrite target)
524
+ // 2. EPERM fallback: write content directly (no readFile+unlink — content
525
+ // is already in memory, orphaned tmp is harmless until next write)
526
+ try {
527
+ await fs.promises.rename(tmpPath, filePath);
528
+ } catch {
529
+ // EPERM on Windows (cross-device or locking): write content directly
530
+ // to target. No readFile+unlink needed — we already have `content` in
531
+ // memory, and the orphaned temp file is harmless until next successful
532
+ // write cleans it up.
533
+ await fs.promises.writeFile(filePath, content, "utf8");
534
+ }
535
+ }
536
+ }