@ngockhoale/ukit 2.4.3 → 2.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,63 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.5.0 - 2026-09-18
6
+
7
+ C20 feature release (14 handoff tasks + 3-lane review): project-owner instructions via a
8
+ seeded root `PROJECT_IMPORTANT.md`, delivered once per context epoch on every supported
9
+ host.
10
+
11
+ - **`PROJECT_IMPORTANT.md` seed-once ownership**: `ukit install` creates the file only
12
+ when missing (`mergeStrategy: skip`, exclusive `'wx'` create, `lstat`-based existence);
13
+ it is never rewritten, merged, chmodded, tracked in `install.json.files`, or deleted —
14
+ reinstalls preserve it byte-for-byte, and delete + reinstall re-seeds.
15
+ - **Once-per-epoch injection**: Claude Code `SessionStart` (including
16
+ `source:'compact'` post-compact) and omp `session_start`/`session_compact` (steer only
17
+ — exactly one copy per epoch) render the file into advisory context; Codex and OpenCode
18
+ get a static read-the-file pointer in `AGENTS.md` (pointer, not runtime delivery).
19
+ - **Bounded deterministic render** (`src/core/projectImportant.js` + installed-runtime
20
+ mirror): 6,000-Unicode-code-point limit with an explicit oversized warning, strict
21
+ UTF-8 decode (malformed input is never injected), BOM excluded from count and body,
22
+ `O_NOFOLLOW`/`O_NONBLOCK` open so symlinks/FIFOs/devices are never followed or hung on,
23
+ and a byte-identical envelope (no volatile metadata) for identical input.
24
+ - **Fail-closed secret gate** (`src/core/sensitiveValueScanner.js`): the exact body to
25
+ be injected is scanned for high-confidence secret shapes; blocks report labels only —
26
+ never a value, excerpt, or hash.
27
+ - **Uninstall backup**: `ukit uninstall` backs the file up to a sibling
28
+ `PROJECT_IMPORTANT.md.ukit-backup[.n]` at project root (`COPYFILE_EXCL`, bounded suffix
29
+ walk, byte-identical reuse, dry-run plans only) and aborts before any managed deletion
30
+ if the backup cannot be made.
31
+ - **Honest-status surface**: `ukit status`/`ukit doctor` report per-check remediation
32
+ classes (`owner-action`/`install-repairable`/`advisory-host-limit`) and label local
33
+ prompt-cache stats as `Local p-cache` — provider telemetry is reported `unknown`, never
34
+ zero.
35
+
36
+ ## 2.4.3 - 2026-09-18
37
+
38
+ C19 liveness-hardening release (23 handoff tasks): a full sweep of the shipped hook and
39
+ runtime surface against the freeze/stall/orphan classes behind the recurring "session
40
+ stands still mid-turn" reports, plus the regression instruments that keep them closed.
41
+
42
+ - **Bounded hook stdin staging (H01)**: `hook-input.sh`/`hook-input.mjs` runtime helpers
43
+ plus bounded-staging fallback blocks in all 16 hook wrapper templates — a producer that
44
+ never closes the pipe can no longer hold a hook open (SIGKILL at the staging bound).
45
+ - **Deadline-aware hot-hook filesystem work**: hook runtime modules self-arm on
46
+ `UKIT_HOOK_DEADLINE_MS` so a wedged read or lock wait exits inside the hook budget
47
+ instead of orphaning a node child.
48
+ - **Process-tree + lock containment**: bounded process-tree kill when a descendant escapes
49
+ its group, atomic quarantine of validated stale lock generations, owner-stamp failure
50
+ no longer runs the callback under an ownerless lock, and deep-freeze + containment
51
+ fail-closed for discovery snapshot reuse.
52
+ - **Liveness suite + classifier**: `src/diagnostics/classifyHang.js`, `tests/liveness/`,
53
+ and the `test:liveness` script — evidence-only hang classification (an external signal
54
+ is not a TERM→KILL leak) with regression scenarios pinned in tests.
55
+ - **Execution-ledger hardening**: bounded route-scoped ledger journal, overflow guard on
56
+ drain commits, journal-local locking, and the Stop coordinator
57
+ (`stop-coordinator.mjs`) with its extracted watchdog path.
58
+ - **Runtime shipping fix**: the install pipeline ships every packaged
59
+ `.claude/ukit/runtime` module via the `ukit-runtime-scripts` directory item (TASK-035),
60
+ closing the class where a new runtime module shipped to npm but never installed.
61
+
5
62
  ## 2.4.2 - 2026-09-16
6
63
 
7
64
  Fix: the gateway resilience posture disabled the only recovery path a buffering gateway
package/README.md CHANGED
@@ -69,6 +69,26 @@ If maintainers roll out a newer CLI build, the in-project workflow still stays t
69
69
  - `.claude/ukit/.ukit/` — installer manifests, metadata, backups
70
70
  - `.ukit/` — hidden shared runtime storage for config, cache, and cross-agent memory
71
71
  - `docs/` — PROJECT / MEMORY / AI_HANDOFF / WORKLOG baseline
72
+ - `PROJECT_IMPORTANT.md` — your project-owner instruction file (see below)
73
+
74
+ ## `PROJECT_IMPORTANT.md` — project-owner instructions
75
+
76
+ `ukit install` seeds a root `PROJECT_IMPORTANT.md` **once**. After that the file is
77
+ yours: UKit never rewrites, merges, formats, chmods, tracks, or deletes it — edits
78
+ survive every `ukit install` rerun byte-for-byte, and deleting it + reinstalling
79
+ seeds a fresh copy. It is also excluded from `ukit uninstall` deletion; uninstall
80
+ backs it up to a sibling `PROJECT_IMPORTANT.md.ukit-backup[.n]` file at project
81
+ root (never overwritten — a new suffix is chosen, and dry-run creates nothing).
82
+
83
+ Put your non-negotiable project rules there. On each session start your AI tool
84
+ receives its contents as advisory project-owner context — Claude Code and omp via
85
+ their session hooks (and once more after a compaction, which starts a new context
86
+ epoch), Codex and OpenCode via a static pointer in `AGENTS.md` (read-the-file
87
+ instructions, not a runtime hook). Keep the file at or below **6,000 Unicode code
88
+ points** — anything past that is truncated with an explicit warning, and the fix is
89
+ to shorten the file, not rerun `ukit install`. Do not put credentials or secrets in
90
+ it: a fail-closed sensitive-value scan runs on the exact body before injection and
91
+ blocks anything that looks like a key or token.
72
92
 
73
93
  ## UKit v1.3.1 Runtime
74
94
 
@@ -19,6 +19,20 @@ items:
19
19
  packs:
20
20
  - core
21
21
 
22
+ # Owner-instruction seed. `mergeStrategy: skip` gives seed-once semantics: UKit writes it
23
+ # only when the path is truly absent (exclusive 'wx' create in applyPlan), never rewrites,
24
+ # merges, formats, chmods or tracks it in install.json afterwards.
25
+ - id: project-important
26
+ type: config
27
+ sourceTemplate: PROJECT_IMPORTANT.md
28
+ targetPath: PROJECT_IMPORTANT.md
29
+ requires: []
30
+ mergeStrategy: skip
31
+ variables: []
32
+ enabledByDefault: true
33
+ packs:
34
+ - core
35
+
22
36
  - id: docs-memory
23
37
  type: config
24
38
  sourceTemplate: docs/MEMORY.md
@@ -987,6 +1001,7 @@ items:
987
1001
  - hook-block-dangerous
988
1002
  - hook-handoff-model-guard
989
1003
  - hook-auto-prune-bash
1004
+ - hook-project-important
990
1005
  # Env block is post-merged by applyGatewayResilienceEnv (TASK-011) to add managed
991
1006
  # gateway-resilience defaults without clobbering user values, so the diff ignores
992
1007
  # the env block — byte-equality on the rest of the file is enough to flag an update.
@@ -1111,11 +1126,15 @@ items:
1111
1126
  packs:
1112
1127
  - core
1113
1128
 
1129
+ # requires: [ukit-runtime-scripts] is forward-declared for the shared sensitive-value
1130
+ # scanner (consumed by the guard once TASK-039 wires it). The guard is fail-closed, so
1131
+ # its runtime dependency must be installed first — keeps the ordering posture explicit.
1114
1132
  - id: hook-sensitive-data-guard
1115
1133
  type: hook
1116
1134
  sourceTemplate: .claude/hooks/sensitive-data-guard.sh
1117
1135
  targetPath: .claude/hooks/sensitive-data-guard.sh
1118
- requires: []
1136
+ requires:
1137
+ - ukit-runtime-scripts
1119
1138
  mergeStrategy: overwrite_with_backup
1120
1139
  variables: []
1121
1140
  enabledByDefault: true
@@ -1255,6 +1274,18 @@ items:
1255
1274
  packs:
1256
1275
  - core
1257
1276
 
1277
+ - id: hook-project-important
1278
+ type: hook
1279
+ sourceTemplate: .claude/hooks/project-important.sh
1280
+ targetPath: .claude/hooks/project-important.sh
1281
+ requires:
1282
+ - ukit-runtime-scripts
1283
+ mergeStrategy: overwrite_with_backup
1284
+ variables: []
1285
+ enabledByDefault: true
1286
+ packs:
1287
+ - core
1288
+
1258
1289
  - id: hook-worklog-session-start
1259
1290
  type: hook
1260
1291
  sourceTemplate: .claude/hooks/session-start.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.4.3",
3
+ "version": "2.5.0",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,10 @@ import {
18
18
  GATEWAY_RESILIENCE_ENV_DEFAULTS,
19
19
  scanProfileResilienceEnv,
20
20
  } from '../../core/gatewayResilienceEnv.js';
21
+ import {
22
+ inspectProjectImportant,
23
+ inspectProjectImportantWiring,
24
+ } from '../../core/projectImportant.js';
21
25
 
22
26
  export const DOCTOR_HELP_FLAGS = new Set(['--help', '-h']);
23
27
  const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills', '--gateway']);
@@ -103,6 +107,109 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
103
107
 
104
108
  const ok = (v) => (v ? '✓' : '✗');
105
109
 
110
+ // TASK-044 — typed PROJECT_IMPORTANT.md checks (spec §14). Each failure
111
+ // carries a class: install-repairable | owner-action | advisory-host-limit.
112
+ const INSTALL_REPAIR = 'Run ukit install';
113
+ const piInspection = await inspectProjectImportant({
114
+ projectRoot,
115
+ config: runtimeConfigInspection.config,
116
+ });
117
+ const piWiring = await inspectProjectImportantWiring(projectRoot, { trackedPaths });
118
+ const piState = piInspection.state;
119
+
120
+ const contentCheck = (failedState, label, remedy) => ({
121
+ label,
122
+ passed: piState !== failedState && piState !== 'missing' && piState !== 'unsafe-type' && piState !== 'unreadable',
123
+ applicable: piState !== 'missing' && piState !== 'unsafe-type' && piState !== 'unreadable',
124
+ failed: piState === failedState,
125
+ remediationClass: 'owner-action',
126
+ remedy,
127
+ });
128
+
129
+ const projectChecks = [
130
+ {
131
+ label: 'PROJECT_IMPORTANT.md exists',
132
+ passed: piState !== 'missing',
133
+ failed: piState === 'missing',
134
+ remediationClass: 'install-repairable',
135
+ remedy: INSTALL_REPAIR,
136
+ },
137
+ {
138
+ label: 'PROJECT_IMPORTANT.md is a regular non-symlink file',
139
+ passed: piState !== 'unsafe-type' && piState !== 'missing',
140
+ failed: piState === 'unsafe-type',
141
+ applicable: piState !== 'missing',
142
+ remediationClass: 'owner-action',
143
+ remedy: 'Replace PROJECT_IMPORTANT.md with a regular file at the project root.',
144
+ },
145
+ {
146
+ label: 'PROJECT_IMPORTANT.md is readable',
147
+ passed: piState !== 'unreadable' && piState !== 'missing' && piState !== 'unsafe-type',
148
+ failed: piState === 'unreadable',
149
+ applicable: piState !== 'missing' && piState !== 'unsafe-type',
150
+ remediationClass: 'advisory-host-limit',
151
+ remedy: 'Check PROJECT_IMPORTANT.md file permissions; the host cannot read it.',
152
+ },
153
+ contentCheck('invalid-utf8', 'PROJECT_IMPORTANT.md is valid UTF-8',
154
+ 'Save PROJECT_IMPORTANT.md as valid UTF-8 without changing its intended content.'),
155
+ contentCheck('unsafe-control', 'PROJECT_IMPORTANT.md free of unsupported control characters',
156
+ 'Remove unsupported control characters from PROJECT_IMPORTANT.md.'),
157
+ contentCheck('empty', 'PROJECT_IMPORTANT.md is not empty',
158
+ 'Add project-owner instructions to PROJECT_IMPORTANT.md.'),
159
+ contentCheck('oversized', 'PROJECT_IMPORTANT.md within 6,000 code points',
160
+ 'Shorten PROJECT_IMPORTANT.md to 6,000 Unicode code points or fewer.'),
161
+ contentCheck('secret-blocked', 'PROJECT_IMPORTANT.md free of unallowlisted secrets',
162
+ 'Redact the secret or explicitly allowlist it.'),
163
+ {
164
+ label: 'Runtime module .claude/ukit/runtime/project-important.mjs',
165
+ passed: piWiring.claude.runtimeModule,
166
+ failed: !piWiring.claude.runtimeModule,
167
+ remediationClass: 'install-repairable',
168
+ remedy: INSTALL_REPAIR,
169
+ },
170
+ {
171
+ label: 'Hook wrapper .claude/hooks/project-important.sh installed and executable',
172
+ passed: piWiring.claude.hookInstalled && piWiring.claude.hookExecutable,
173
+ failed: !(piWiring.claude.hookInstalled && piWiring.claude.hookExecutable),
174
+ remediationClass: 'install-repairable',
175
+ remedy: INSTALL_REPAIR,
176
+ },
177
+ {
178
+ label: 'Claude settings SessionStart order (project-important.sh first)',
179
+ passed: piWiring.claude.settingsWired,
180
+ failed: !piWiring.claude.settingsWired,
181
+ remediationClass: 'install-repairable',
182
+ remedy: INSTALL_REPAIR,
183
+ },
184
+ ];
185
+ if (piWiring.omp.installed) {
186
+ projectChecks.push({
187
+ label: 'omp bridge session_start order (project-important.sh first)',
188
+ passed: piWiring.omp.wired,
189
+ failed: !piWiring.omp.wired,
190
+ remediationClass: 'install-repairable',
191
+ remedy: INSTALL_REPAIR,
192
+ });
193
+ }
194
+ if (piWiring.codex.installed) {
195
+ projectChecks.push({
196
+ label: 'Codex fallback present (AGENTS.md owner-instructions section)',
197
+ passed: piWiring.codex.wired,
198
+ failed: !piWiring.codex.wired,
199
+ remediationClass: 'install-repairable',
200
+ remedy: INSTALL_REPAIR,
201
+ });
202
+ }
203
+ if (piWiring.opencode.installed) {
204
+ projectChecks.push({
205
+ label: 'OpenCode fallback present (AGENTS.md owner-instructions section)',
206
+ passed: piWiring.opencode.wired,
207
+ failed: !piWiring.opencode.wired,
208
+ remediationClass: 'install-repairable',
209
+ remedy: INSTALL_REPAIR,
210
+ });
211
+ }
212
+
106
213
  const providerNames = Object.keys(providers.providers);
107
214
  const providerStatus = providerNames
108
215
  .map((name) => `${name}=${ok(providers.providers[name].supported)}`)
@@ -139,6 +246,12 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
139
246
  }
140
247
  console.log(`[UKit] ${ok(checks.allProvidersConfigured)} All providers configured`);
141
248
 
249
+ console.log('[UKit] Project rules checks:');
250
+ for (const check of projectChecks) {
251
+ if (check.applicable === false) continue;
252
+ console.log(`[UKit] ${ok(check.passed)} ${check.label}`);
253
+ }
254
+
142
255
  if (runtimeConfigInspection.errors.length > 0) {
143
256
  console.log(`[UKit] Runtime config issues: ${runtimeConfigInspection.errors.join(' | ')}`);
144
257
  }
@@ -254,8 +367,25 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
254
367
  }
255
368
 
256
369
  const allPassed = Object.values(checks).every(Boolean);
257
- if (!allPassed) {
258
- console.log('[UKit] Some checks failed. Run `ukit install` to fix missing files.');
370
+ const failedProjectChecks = projectChecks.filter(
371
+ (check) => check.applicable !== false && !check.passed,
372
+ );
373
+ const blockingFailures = failedProjectChecks.filter(
374
+ (check) => check.remediationClass === 'install-repairable' || check.remediationClass === 'owner-action',
375
+ );
376
+
377
+ if (failedProjectChecks.length > 0) {
378
+ console.log('[UKit] Remedies:');
379
+ for (const check of failedProjectChecks) {
380
+ console.log(`[UKit] - [${check.remediationClass}] ${check.remedy}`);
381
+ }
382
+ }
383
+
384
+ if (!allPassed || blockingFailures.length > 0) {
385
+ console.log('[UKit] Some checks failed.');
386
+ if (!allPassed && blockingFailures.length === 0) {
387
+ console.log('[UKit] Re-run `ukit install` to repair install-managed files.');
388
+ }
259
389
  process.exitCode = 1;
260
390
  } else {
261
391
  console.log('[UKit] All checks passed.');
@@ -42,10 +42,28 @@ export async function runUninstall({ projectRoot, argv = [] }) {
42
42
  for (const p of result.wouldRemove) {
43
43
  console.log(` - ${path.relative(projectRoot, p)}`);
44
44
  }
45
+ for (const p of result.preservedPaths ?? []) {
46
+ console.log(`[UKit] Preserved ${path.basename(p)}.`);
47
+ }
48
+ if (result.wouldBackup && (result.backupPaths ?? []).length > 0) {
49
+ console.log(`[UKit] Would back up to ${path.basename(result.backupPaths[0])}.`);
50
+ }
51
+ for (const warning of result.backupWarnings ?? []) {
52
+ console.log(warning);
53
+ }
45
54
  console.log(`[UKit] Would remove ${result.wouldRemove.length} path(s). Run without --dry-run to actually uninstall.`);
46
55
  return;
47
56
  }
48
57
 
58
+ for (const p of result.preservedPaths ?? []) {
59
+ console.log(`[UKit] Preserved ${path.basename(p)}.`);
60
+ }
61
+ for (const p of result.backupPaths ?? []) {
62
+ console.log(`[UKit] Backup: ${path.basename(p)}`);
63
+ }
64
+ for (const warning of result.backupWarnings ?? []) {
65
+ console.log(warning);
66
+ }
49
67
  console.log(`[UKit] Uninstall complete. Removed ${result.removed}/${result.attempted} managed paths.`);
50
68
  console.log('[UKit] Note: docs/PROJECT.md, docs/MEMORY.md, docs/AI_HANDOFF/, docs/WORKLOG.md contain user content and were preserved. Delete manually if needed.');
51
69
  }
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { writeFileAtomic, copyFileSafe, createDirectoryLink, removeLinkOnly } from './fileOps.js';
3
+ import { writeFileAtomic, writeFileExclusive, copyFileSafe, createDirectoryLink, removeLinkOnly } from './fileOps.js';
4
4
 
5
5
  // legacy: '.antigravity/' stays TCC-protected — Antigravity adapter removed in v2.2.0, but a
6
6
  // stale install may still have the dir and must not lose TCC protection.
@@ -107,9 +107,24 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
107
107
  )
108
108
  : entry.renderedContent;
109
109
 
110
+ // Race-safe seed: a `create` entry with `mergeStrategy: skip` must not overwrite a
111
+ // target that appeared after the diff ran. Exclusive 'wx' create fails with EEXIST
112
+ // instead — that becomes a skipped create (no rewrite, no chmod, no tracked write),
113
+ // and install continues. Temp+rename is NOT used here because rename overwrites.
114
+ const exclusiveSkipCreate = entry.action === 'create' && entry.mergeStrategy === 'skip';
115
+
110
116
  try {
111
- await writeFileAtomic(entry.targetPath, nextContent);
117
+ if (exclusiveSkipCreate) {
118
+ await writeFileExclusive(entry.targetPath, nextContent);
119
+ } else {
120
+ await writeFileAtomic(entry.targetPath, nextContent);
121
+ }
112
122
  } catch (writeError) {
123
+ if (writeError.code === 'EEXIST' && exclusiveSkipCreate) {
124
+ skippedUpdates += 1;
125
+ skippedByAction.create += 1;
126
+ continue;
127
+ }
113
128
  if (
114
129
  (writeError.code === 'EPERM' || writeError.code === 'EACCES') &&
115
130
  isTccProtectedPath(entry.targetPath)
@@ -132,6 +132,37 @@ function resolveFileAction(entry, existingContent) {
132
132
  return { ...entry, exists, action, existingContent };
133
133
  }
134
134
 
135
+ // A `mergeStrategy: skip` target must never be classified as "missing" just because it
136
+ // cannot be READ — a directory, broken symlink, FIFO/device, or unreadable (EACCES) file
137
+ // at the seed path all mean "exists, do not touch". readFileOrNull would turn every one
138
+ // of those into null → 'create' → an overwrite attempt. Existence for skip entries is
139
+ // decided by lstat (no symlink follow, no device open — reading a FIFO would hang).
140
+ async function resolveSkipEntryAction(entry) {
141
+ let stat;
142
+ try {
143
+ stat = await fs.lstat(entry.targetPath);
144
+ } catch {
145
+ // ENOENT (or a stat failure we cannot distinguish from it) is the only state that
146
+ // means "seed me"; the apply step still uses an exclusive 'wx' create for the race.
147
+ return { ...entry, exists: false, action: 'create', existingContent: null };
148
+ }
149
+
150
+ // Only a regular, readable file can prove byte-equality for an 'unchanged' verdict.
151
+ // Anything else — directory, symlink (valid or broken), FIFO, socket, device, or a
152
+ // file we cannot read — is simply 'skip': present, owner-owned, hands off.
153
+ let existingContent = null;
154
+ if (stat.isFile() && !stat.isSymbolicLink()) {
155
+ existingContent = await readFileOrNull(
156
+ entry.targetPath,
157
+ Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
158
+ );
159
+ }
160
+ if (existingContent === null) {
161
+ return { ...entry, exists: true, action: 'skip', existingContent: null };
162
+ }
163
+ return resolveFileAction(entry, existingContent);
164
+ }
165
+
135
166
  export async function diffInstallPlan(plan) {
136
167
  // Check all entries in parallel
137
168
  const results = await Promise.all(
@@ -141,6 +172,10 @@ export async function diffInstallPlan(plan) {
141
172
  return { ...entry, exists: action !== 'create', action, existingContent: null };
142
173
  }
143
174
 
175
+ if (entry.mergeStrategy === 'skip') {
176
+ return resolveSkipEntryAction(entry);
177
+ }
178
+
144
179
  const existingContent = await readFileOrNull(
145
180
  entry.targetPath,
146
181
  Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
@@ -128,6 +128,32 @@ export async function copyFileSafe(fromPath, toPath) {
128
128
  await fs.copyFile(fromPath, toPath);
129
129
  }
130
130
 
131
+ /**
132
+ * Exclusive-create write ('wx'): succeeds only when `filePath` does not already exist.
133
+ * Used for seed-once (`mergeStrategy: skip`) creates so a file that appears between
134
+ * diff and apply is never overwritten — the caller maps EEXIST to a skipped create.
135
+ * Never used as a temp+rename pair: rename could overwrite a target that just appeared.
136
+ * @param {string} filePath
137
+ * @param {string|Buffer} content - raw bytes; no encoding/normalization is applied
138
+ */
139
+ export async function writeFileExclusive(filePath, content) {
140
+ await ensureDir(path.dirname(filePath));
141
+ await fs.writeFile(filePath, content, { flag: 'wx' });
142
+ }
143
+
144
+ /**
145
+ * Raw-byte copy into a path that must not already exist (exclusive destination create,
146
+ * `fs.constants.COPYFILE_EXCL`). No decode/re-encode — the destination is byte-identical
147
+ * to the source. Source is opened by path; callers needing no-follow semantics must
148
+ * verify the source type before calling. EEXIST propagates for the caller to handle.
149
+ * @param {string} fromPath
150
+ * @param {string} toPath
151
+ */
152
+ export async function copyFileRawExclusive(fromPath, toPath) {
153
+ await ensureDir(path.dirname(toPath));
154
+ await fs.copyFile(fromPath, toPath, fs.constants.COPYFILE_EXCL);
155
+ }
156
+
131
157
  export async function writeJson(filePath, data) {
132
158
  await writeFileAtomic(filePath, `${JSON.stringify(data, null, 2)}\n`);
133
159
  }