@lmzhen/dsh-evolution-core 0.26.26 → 0.27.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 (107) hide show
  1. package/README.md +16 -1
  2. package/lib/composition/index.js +3 -0
  3. package/lib/composition-D8Y3-ROd.js +604 -0
  4. package/lib/ensure-MEYMj9jn.js +222 -0
  5. package/lib/errors-Br2-uUSK.js +57 -0
  6. package/lib/http-routes-BP-S3KV-.js +149 -0
  7. package/lib/http-routes.js +2 -0
  8. package/lib/index.js +66 -850
  9. package/lib/restrictor.js +37 -0
  10. package/lib/subagent/index.js +186 -0
  11. package/lib/types/citations.js +192 -0
  12. package/lib/types/{preset-composition.d.ts → composition/composer.d.ts} +1 -1
  13. package/lib/types/composition/composer.js +681 -0
  14. package/lib/types/composition/ensure.d.ts +10 -0
  15. package/lib/types/composition/ensure.js +95 -0
  16. package/lib/types/composition/index.d.ts +5 -0
  17. package/lib/types/composition/index.js +7 -0
  18. package/lib/types/composition/preset-ids.d.ts +17 -0
  19. package/lib/types/composition/preset-ids.js +21 -0
  20. package/lib/types/composition/restricted-preset.d.ts +36 -0
  21. package/lib/types/composition/restricted-preset.js +42 -0
  22. package/lib/types/constants.js +223 -0
  23. package/lib/types/cost.js +67 -0
  24. package/lib/types/curator.js +346 -0
  25. package/lib/types/diagnostics.d.ts +26 -0
  26. package/lib/types/diagnostics.js +23 -0
  27. package/lib/types/document-body.js +91 -0
  28. package/lib/types/drift-signals.js +319 -0
  29. package/lib/types/env.js +25 -0
  30. package/lib/types/errors.d.ts +4 -1
  31. package/lib/types/errors.js +59 -0
  32. package/lib/types/events.js +16 -0
  33. package/lib/types/evidence.js +65 -0
  34. package/lib/types/evolution-events.js +479 -0
  35. package/lib/types/fold-untrusted.js +44 -0
  36. package/lib/types/frontmatter.js +623 -0
  37. package/lib/types/fuzzy-match.js +135 -0
  38. package/lib/types/gates.js +51 -0
  39. package/lib/types/guards.js +27 -0
  40. package/lib/types/health-baseline.js +63 -0
  41. package/lib/types/http-routes.d.ts +67 -35
  42. package/lib/types/http-routes.js +135 -0
  43. package/lib/types/index.d.ts +7 -2
  44. package/lib/types/index.js +101 -0
  45. package/lib/types/instance-scope.js +69 -0
  46. package/lib/types/io.js +675 -0
  47. package/lib/types/learn-prompt.js +42 -0
  48. package/lib/types/limits.js +63 -0
  49. package/lib/types/live.js +107 -0
  50. package/lib/types/long-runs.js +96 -0
  51. package/lib/types/memory-store.d.ts +0 -16
  52. package/lib/types/memory-store.js +606 -0
  53. package/lib/types/mutations.js +95 -0
  54. package/lib/types/namespace-migration.js +156 -0
  55. package/lib/types/numeric.js +47 -0
  56. package/lib/types/opt-in.js +101 -0
  57. package/lib/types/package-identity.js +47 -0
  58. package/lib/types/params.js +328 -0
  59. package/lib/types/payload.js +36 -0
  60. package/lib/types/platform-line.generated.d.ts +10 -0
  61. package/lib/types/platform-line.generated.js +12 -0
  62. package/lib/types/platform-services.d.ts +2 -2
  63. package/lib/types/platform-services.js +52 -0
  64. package/lib/types/probe.js +122 -0
  65. package/lib/types/prompts.js +409 -0
  66. package/lib/types/quality.js +308 -0
  67. package/lib/types/reachability.js +122 -0
  68. package/lib/types/redact.js +257 -0
  69. package/lib/types/reference-rewrite.js +144 -0
  70. package/lib/types/reports.js +110 -0
  71. package/lib/types/restrictor.d.ts +11 -0
  72. package/lib/types/restrictor.js +48 -0
  73. package/lib/types/review-channel.js +59 -0
  74. package/lib/types/run-registry.js +345 -0
  75. package/lib/types/scope.js +7 -0
  76. package/lib/types/serial.js +17 -0
  77. package/lib/types/session-fold.d.ts +15 -0
  78. package/lib/types/session-fold.js +28 -0
  79. package/lib/types/session-projection.js +295 -0
  80. package/lib/types/signals.js +209 -0
  81. package/lib/types/skill-discovery.js +66 -0
  82. package/lib/types/skill-health.js +134 -0
  83. package/lib/types/skill-history.js +745 -0
  84. package/lib/types/skill-lint.js +112 -0
  85. package/lib/types/skill-reads.js +67 -0
  86. package/lib/types/skill-row-cells.js +123 -0
  87. package/lib/types/skill-store.js +3528 -0
  88. package/lib/types/state-store.js +60 -0
  89. package/lib/types/store-ledger.js +117 -0
  90. package/lib/types/subagent/channel.d.ts +4 -0
  91. package/lib/types/subagent/channel.js +131 -0
  92. package/lib/types/subagent/index.d.ts +4 -0
  93. package/lib/types/subagent/index.js +5 -0
  94. package/lib/types/subagent/readiness.d.ts +6 -0
  95. package/lib/types/subagent/readiness.js +35 -0
  96. package/lib/types/subagent/types.d.ts +58 -0
  97. package/lib/types/subagent/types.js +2 -0
  98. package/lib/types/threats.js +338 -0
  99. package/lib/types/tool-dispatch.d.ts +5 -0
  100. package/lib/types/tool-dispatch.js +535 -0
  101. package/lib/types/tree-write.js +40 -0
  102. package/lib/types/unit-members.js +121 -0
  103. package/lib/types/usage.js +474 -0
  104. package/lib/types/write-authority.js +23 -0
  105. package/lib/types/write-gates.js +344 -0
  106. package/lib/types/write-inventory.js +158 -0
  107. package/package.json +39 -11
@@ -0,0 +1,675 @@
1
+ /**
2
+ * Structural IO seam for the evolution plugin family.
3
+ *
4
+ * Every evolution package passes `ctx.evolutionIo.provider()`; standalone
5
+ * consumers (and the core's own tests) can use `nodeEvolutionIo`.
6
+ */
7
+ import { copyFileSync, existsSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
+ import { cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises';
9
+ import { basename, dirname, join } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+ // v43 S1-4: the family's ONE definition of "this read failure means it is not
12
+ // there" lives in probe.ts; this backend delegates instead of keeping a second
13
+ // copy of the same two codes.
14
+ import { isMissingPath } from "./probe.js";
15
+ /**
16
+ * Run `task` inside `io.transact` when the backend provides it; otherwise fall
17
+ * back to a plain read → task → write/remove sequence (no cross-process lock —
18
+ * callers keep their single-process serialize chain as the second layer). A
19
+ * byte-identical task result skips the write (C-07 — parity with the
20
+ * node backend's V5-03 short-circuit).
21
+ */
22
+ export async function transactIo(io, path, task) {
23
+ // V27 G1.3: a dir-fsync failure AFTER the rename (`committed: true`) means the
24
+ // bytes ARE on disk. Every durable consumer in this family reaches its write
25
+ // through this helper, so the tolerance lives here instead of in seven
26
+ // packages: propagating it made memory/usage/mutations/state/activity/
27
+ // feedback/events report "not written" for a write that happened, and a
28
+ // two-phase caller could then roll back a visible write. The durability loss
29
+ // is still observable — it is logged, not swallowed.
30
+ const committedOnly = (error) => isCommittedWarning(error);
31
+ if (io.transact) {
32
+ try {
33
+ await io.transact(path, task);
34
+ return;
35
+ }
36
+ catch (error) {
37
+ if (!committedOnly(error))
38
+ throw error;
39
+ console.warn(`evolution-io: ${path} was written but its directory fsync failed — the bytes are visible, durability is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
40
+ return;
41
+ }
42
+ }
43
+ const current = await io.readText(path);
44
+ const next = await task(current);
45
+ if (next === null)
46
+ await io.remove(path);
47
+ // C-07: a byte-identical result is a no-op — the same V5-03
48
+ // short-circuit the node backend's transact already has. Without it every
49
+ // fallback RMW churned a tmp+rename and touched the mtime even when nothing
50
+ // changed, so transact-less backends behaved asymmetrically.
51
+ else if (next !== current) {
52
+ try {
53
+ await io.writeText(path, next);
54
+ }
55
+ catch (error) {
56
+ if (!committedOnly(error))
57
+ throw error;
58
+ console.warn(`evolution-io: ${path} was written but its directory fsync failed — the bytes are visible, durability is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
59
+ }
60
+ }
61
+ }
62
+ /** Build a {@link TransactTaskGuard} for one write path. See its doc. */
63
+ export function transactTaskGuard(what) {
64
+ let invoked = false;
65
+ return {
66
+ wrap: (task) => (current) => {
67
+ invoked = true;
68
+ return task(current);
69
+ },
70
+ invoked: () => invoked,
71
+ assertInvoked: () => {
72
+ if (invoked)
73
+ return;
74
+ throw new Error(`internal error: the write transaction for ${what} did not invoke the task; no write was performed`);
75
+ },
76
+ };
77
+ }
78
+ /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
79
+ export function evolutionIoAdapter(provider) {
80
+ return {
81
+ readText: path => provider().readText(path),
82
+ writeText: (path, content) => provider().writeText(path, content),
83
+ remove: path => provider().remove(path),
84
+ list: path => provider().list(path),
85
+ exists: path => provider().exists(path),
86
+ rename: (path, destination) => provider().rename(path, destination),
87
+ copy: (path, destination) => provider().copy(path, destination),
88
+ // Never throws: a backend without a size probe means "guard not applicable".
89
+ size: (path) => {
90
+ const io = provider();
91
+ return io.size ? io.size(path) : Promise.resolve(null);
92
+ },
93
+ // Transact and symlink probes are forwarding-only: the fallback semantics
94
+ // live in the consumer helper / the backend itself.
95
+ transact: (path, task) => {
96
+ const io = provider();
97
+ return io.transact ? io.transact(path, task) : transactIo(io, path, task);
98
+ },
99
+ isSymlink: (path) => {
100
+ const io = provider();
101
+ return io.isSymlink ? io.isSymlink(path) : Promise.resolve(null);
102
+ },
103
+ // Never throws: a backend without the probe reports null (cache keeps its
104
+ // event-driven invalidation only — 0.3.18, E-71).
105
+ mtime: (path) => {
106
+ const io = provider();
107
+ return io.mtime ? io.mtime(path) : Promise.resolve(null);
108
+ },
109
+ };
110
+ }
111
+ // C-28: the transient-EPERM/EBUSY retry budget grows from 3x50ms
112
+ // (~150ms — shorter than a real antivirus/indexer scan window) to ~2s, the
113
+ // same magnitude as the write-lock retry budget (rc.69), with exponential
114
+ // backoff so a short hold recovers fast and a long hold still recovers.
115
+ const RENAME_RETRY_BASE_MS = 50;
116
+ const RENAME_RETRY_MAX_DELAY_MS = 800;
117
+ // 50 + 100 + 200 + 400 + 800 + 800 = 2350ms of backoff (~2s budget).
118
+ const RENAME_RETRY_MAX_ATTEMPTS = 6;
119
+ /**
120
+ * Retry a rename that a peer is temporarily holding on Windows (EPERM/EBUSY):
121
+ * exponential backoff (50ms doubling, capped at 800ms) with a ~2s total
122
+ * budget, matching the write-lock cadence (C-28 — the old 3x50ms
123
+ * budget turned a transient antivirus hold into a permanent write failure).
124
+ * A non-transient code surfaces immediately; a persistent EPERM/EBUSY
125
+ * rethrows with a pointer at the usual causes instead of a bare errno.
126
+ * `fn` is the rename primitive, injectable for deterministic tests.
127
+ *
128
+ * v22 (LOCK-5) documented platform limit: POSIX rename-over-existing is
129
+ * atomic for concurrent READERS, but Windows MoveFileExW(REPLACE_EXISTING)
130
+ * can make the target briefly invisible (ENOENT) while the replacement is in
131
+ * flight. The retry budget above covers the WRITE side only — a LOCKLESS
132
+ * reader (`readText`/`readJson` on a path it does not hold the lock for) can
133
+ * observe that window and see "missing" for a file that was just committed.
134
+ * Verified harmless today: every lockless-read consumer treats the transient
135
+ * miss as self-healing state (no consumer persists a read of null into a
136
+ * write), and distinguishing that ENOENT from a genuinely absent file in
137
+ * `readText` would tax every ordinary missing-file probe. Revisit only if a
138
+ * consumer appears that must never transiently miss.
139
+ *
140
+ * @param tmp - the source path to rename.
141
+ * @param target - the destination path.
142
+ * @param fn - the rename primitive (defaults to `node:fs/promises.rename`).
143
+ * @returns a promise that resolves once the rename succeeds.
144
+ */
145
+ export async function renameWithRetry(tmp, target, fn = rename) {
146
+ for (let retry = 0;; retry += 1) {
147
+ try {
148
+ await fn(tmp, target);
149
+ return;
150
+ }
151
+ catch (error) {
152
+ const code = error?.code;
153
+ if (code !== 'EPERM' && code !== 'EBUSY')
154
+ throw error;
155
+ if (retry >= RENAME_RETRY_MAX_ATTEMPTS) {
156
+ // C-28: keep the original message (callers match on it) and
157
+ // append the direction a bare EPERM never gave: the target — not the
158
+ // tmp — is the thing being held or refused.
159
+ const final = error;
160
+ final.message = `${final.message} (persisted after ${retry + 1} rename attempts over ~2s — the target may be held by another process or marked read-only; check antivirus, search indexers and file attributes)`;
161
+ throw final;
162
+ }
163
+ await new Promise(resolve => setTimeout(resolve, Math.min(RENAME_RETRY_BASE_MS * 2 ** retry, RENAME_RETRY_MAX_DELAY_MS)));
164
+ }
165
+ }
166
+ }
167
+ /**
168
+ * V10-06 (P1-1): the crash-durable tmp half of the upstream storage-json
169
+ * `writeAtomic` protocol (storage-json/src/atomic.ts:24-40): exclusive-create
170
+ * a same-directory tmp (`wx` — never clobbers), write, `handle.sync()`,
171
+ * close, then hand the tmp to `commitTmp` for the rename. Without the fsync a
172
+ * power loss could land the rename (metadata) before the data blocks and
173
+ * leave an empty/truncated target — which the state-json E-9 quarantine then
174
+ * amplifies into a permanent fail-loud. The tmp name keeps the
175
+ * `<target>.<pid>.<rand>.tmp` shape so sweepStaleTmps keeps matching.
176
+ * Mode parity note: no explicit mode is passed (upstream uses 0o600) — this
177
+ * seam also writes operator-editable skill/memory files, so the previous
178
+ * `writeFile` default (0o666 & ~umask) is deliberately preserved.
179
+ * `openImpl` is injectable so the sync-failure regression test can drive a
180
+ * failing `handle.sync()` deterministically.
181
+ */
182
+ export async function writeDurableTmp(target, content, openImpl = open) {
183
+ const tmp = `${target}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
184
+ try {
185
+ // P3-9 (v14): commitTmp renames the tmp ONTO the target, so the target
186
+ // inherits the tmp's inode and mode. Without carrying the target's own
187
+ // mode, a file an operator tightened to 0o600 silently widened to
188
+ // 0o666 & ~umask on the next write. POSIX only — Windows ignores mode bits.
189
+ const mode = process.platform === 'win32'
190
+ ? undefined
191
+ : await stat(target).then(value => value.mode & 0o777).catch(() => undefined);
192
+ const handle = await openImpl(tmp, 'wx', mode);
193
+ try {
194
+ await handle.writeFile(content, 'utf8');
195
+ await handle.sync();
196
+ }
197
+ finally {
198
+ await handle.close();
199
+ }
200
+ return tmp;
201
+ }
202
+ catch (error) {
203
+ // Never leak the tmp of a failed durable write (same tail as upstream).
204
+ await rm(tmp, { force: true }).catch(() => { });
205
+ throw error;
206
+ }
207
+ }
208
+ /** V10-06 (P1-1): fsync a POSIX directory so a just-renamed entry is
209
+ * crash-durable (upstream atomic.ts `fsyncDirectory`). */
210
+ /* v8 ignore start -- Windows rejects O_RDONLY directory opens; POSIX coverage exercises this. */
211
+ async function fsyncDirectory(path) {
212
+ if (process.platform === 'win32')
213
+ return;
214
+ const handle = await open(path, 'r');
215
+ try {
216
+ await handle.sync();
217
+ }
218
+ finally {
219
+ await handle.close();
220
+ }
221
+ }
222
+ /* v8 ignore stop */
223
+ /**
224
+ * F-366: commit a freshly-written tmp to its target inside the write lock. On a
225
+ * still-failing rename the tmp is deleted immediately rather than left for the
226
+ * (1h + dead-pid) sweep, so a live writer never leaks a tmp it abandoned.
227
+ * V10-06 (P1-1): after a successful rename the parent directory is fsynced on
228
+ * POSIX so the new directory entry itself is crash-durable (upstream atomic.ts
229
+ * tail; Windows skips — it rejects O_RDONLY directory opens). A dir-fsync
230
+ * failure propagates like upstream: the data is on disk, but the durability
231
+ * contract failed loud.
232
+ */
233
+ async function commitTmp(tmp, target) {
234
+ try {
235
+ await renameWithRetry(tmp, target);
236
+ }
237
+ catch (error) {
238
+ await rm(tmp, { force: true }).catch(() => { });
239
+ throw error;
240
+ }
241
+ try {
242
+ await fsyncDirectory(dirname(target));
243
+ }
244
+ catch (error) {
245
+ // A1-15 (v18): the rename already landed, so the bytes ARE on disk and the
246
+ // tmp is gone. Mark the failure so the write path can still audit/notify
247
+ // and report "written, durability unconfirmed" instead of a plain failure
248
+ // that a two-phase caller would try to roll back (deleting a visible write).
249
+ throw Object.assign(new Error(`commitTmp: ${target} was renamed but the directory fsync failed: ${error instanceof Error ? error.message : String(error)}`), { committed: true, cause: error });
250
+ }
251
+ }
252
+ /**
253
+ * True when the pid is alive (EPERM = alive but unowned; ESRCH = gone).
254
+ * V18 single source: the node backend's lock takeover and SkillLibrary's
255
+ * stranded-lock sweep must use the same liveness rule.
256
+ */
257
+ export function isProcessAlive(pid) {
258
+ try {
259
+ process.kill(pid, 0);
260
+ return true;
261
+ }
262
+ catch (error) {
263
+ return error?.code === 'EPERM';
264
+ }
265
+ }
266
+ /** How long an abandoned quarantine copy is kept for operator rescue. G2 S4: hoisted to module scope
267
+ * (it used to live inside `nodeEvolutionIo`, in the region the lock protocol occupied). */
268
+ const CORRUPT_SWEEP_AGE_MS = 7 * 24 * 3_600_000;
269
+ /** V27 G1.4: every quarantine-copy name this family has ever produced —
270
+ * `<file>.corrupt` (current fixed name), `<file>.corrupt.<epoch>`, and the
271
+ * v10-05 legacy `<file>.corrupt-<epoch>-<rand>` series. A user support file
272
+ * keeps a final extension (`.corrupt-backup.md`) and therefore stays. */
273
+ const CORRUPT_COPY_RE = /\.corrupt(\.\d+|-\d+-[0-9a-z]+)?$/;
274
+ /**
275
+ * V27 G1.3: the error `commitTmp` throws when the rename landed but the parent
276
+ * directory fsync failed — the bytes ARE visible, only their durability is
277
+ * unconfirmed. A consumer that treats it as a plain failure reports "not
278
+ * written" for a write that happened (and a two-phase caller may try to roll
279
+ * back a visible write). Every transaction consumer must therefore treat this
280
+ * shape as SUCCESS-with-warning, never as a rejection.
281
+ */
282
+ export function isCommittedWarning(error) {
283
+ return error?.committed === true;
284
+ }
285
+ /**
286
+ * Build the Node IO backend: plain fs promises, durable tmp + rename commits (crash atomicity) and
287
+ * ONE concurrency guarantee of its own — writes to the same path are serialized in-process (see
288
+ * `inProcessWrite`). G2 S4 removed the cross-process write lock and its takeover protocol; the
289
+ * plugin path gets cross-process safety from the platform provider (`ctx.fs`).
290
+ */
291
+ export function nodeEvolutionIo() {
292
+ /**
293
+ * v43 S1-4: delegates to the canonical `isMissingPath` (probe.ts) — one
294
+ * definition for the whole family instead of a second copy of the same two
295
+ * codes. EISDIR deliberately stays OUT of that predicate: a directory
296
+ * squatting on a file path is not "absent" — rotation and event reads must
297
+ * still see it as malformed (rc.72 G-2), while the SkillLibrary.read boundary
298
+ * absorbs EISDIR into "absent" for its own surface (E-43).
299
+ * @param error - the caught read failure.
300
+ * @returns true only for ENOENT/ENOTDIR.
301
+ */
302
+ const isMissing = (error) => isMissingPath(error);
303
+ /** True when the pid is alive (single source: `isProcessAlive`). */
304
+ const isAlive = isProcessAlive;
305
+ /**
306
+ * G2 S4: the family write-lock protocol is retired. Concurrency is the PLATFORM’s job now — the
307
+ * plugin path requires `ctx.fs`, whose writes are versioned — so this backend keeps exactly one
308
+ * guarantee of its own: writes to the SAME path inside THIS process are serialized (a promise
309
+ * chain per path: no timers, no lock files, no takeover windows). Cross-process RMW safety comes
310
+ * from the provider; a standalone caller sharing a directory across processes must not rely on
311
+ * this backend alone. The durable tmp + rename commit stays — crash atomicity and mutual
312
+ * exclusion are different concerns.
313
+ */
314
+ const queues = new Map();
315
+ const inProcessWrite = async (path, run) => {
316
+ const previous = queues.get(path) ?? Promise.resolve();
317
+ const result = previous.then(run, run);
318
+ const settled = result.then(() => { }, () => { });
319
+ queues.set(path, settled);
320
+ try {
321
+ return await result;
322
+ }
323
+ finally {
324
+ if (queues.get(path) === settled)
325
+ queues.delete(path);
326
+ }
327
+ };
328
+ /**
329
+ * Sweep transient files a crashed writer left behind, lazily: once per write, for the directory
330
+ * that write is about to touch.
331
+ * `<target>.<pid>.<rand>.tmp` — the durable-write tmp. This process's own leftover is recycled
332
+ * immediately (a live writer's tmp is a current write, not a crash artifact); a foreign tmp
333
+ * needs an age past an hour AND a dead holder.
334
+ * `.corrupt*` — quarantine copies past their rescue window.
335
+ * G2 S4: the lock and takeover-ticket branches are gone with the write-lock protocol.
336
+ */
337
+ const sweepStaleTmps = async (path) => {
338
+ const dir = dirname(path);
339
+ const base = basename(path);
340
+ let entries;
341
+ try {
342
+ entries = await readdir(dir);
343
+ }
344
+ catch {
345
+ return;
346
+ }
347
+ for (const name of entries) {
348
+ // A1-9 (v18): only a tmp whose EXACT prefix is our base belongs to this target; the old
349
+ // `startsWith(prefix) && endsWith('.tmp')` shape also matched a SIBLING target's tmp.
350
+ const tmpMatch = /^(.*)\.(\d+)\.([0-9a-f]+)\.tmp$/.exec(name);
351
+ if (tmpMatch !== null && tmpMatch[1] === base) {
352
+ const tmpPath = join(dir, name);
353
+ const holder = Number(tmpMatch[2]);
354
+ try {
355
+ const st = await stat(tmpPath);
356
+ const deadHolder = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
357
+ const selfLeftover = holder === process.pid;
358
+ if (selfLeftover || (Date.now() - st.mtimeMs > 3_600_000 && deadHolder)) {
359
+ await rm(tmpPath, { force: true });
360
+ }
361
+ }
362
+ catch {
363
+ // The tmp vanished (or a race with its reaper); nothing to clean.
364
+ }
365
+ continue;
366
+ }
367
+ // S-10: a quarantine copy past the rescue window is removed (best-effort: a vanished file
368
+ // needs no cleanup). A1-1 (v18): the namespace layer reserves `.corrupt`/`.tmp` suffixes.
369
+ // Only names belonging to THIS target's series (the same `base.` prefix filter the tmps get):
370
+ // without it a sibling's quarantine copy was fair game.
371
+ if (name.startsWith(base + '.') && CORRUPT_COPY_RE.test(name)) {
372
+ const corruptPath = join(dir, name);
373
+ try {
374
+ const st = await stat(corruptPath);
375
+ if (Date.now() - st.mtimeMs > CORRUPT_SWEEP_AGE_MS)
376
+ await rm(corruptPath, { force: true });
377
+ }
378
+ catch {
379
+ // The copy vanished (or raced its reaper); nothing to clean.
380
+ }
381
+ }
382
+ }
383
+ };
384
+ return {
385
+ async readText(path) {
386
+ try {
387
+ return await readFile(path, 'utf8');
388
+ }
389
+ catch (error) {
390
+ // Only "missing" maps to null (the "not there" signal); any other
391
+ // read failure (EACCES/EIO/EMFILE) surfaces so a caller never
392
+ // mistakes an unreadable store for an empty one and overwrites it.
393
+ if (isMissing(error))
394
+ return null;
395
+ throw error;
396
+ }
397
+ },
398
+ async writeText(path, content) {
399
+ await mkdir(dirname(path), { recursive: true });
400
+ // S4: serialized in-process; the durable tmp + rename commit is unchanged.
401
+ await inProcessWrite(path, async () => {
402
+ await sweepStaleTmps(path);
403
+ const tmp = await writeDurableTmp(path, content);
404
+ await commitTmp(tmp, path);
405
+ });
406
+ },
407
+ async transact(path, task) {
408
+ await mkdir(dirname(path), { recursive: true });
409
+ await inProcessWrite(path, async () => {
410
+ await sweepStaleTmps(path);
411
+ let current;
412
+ try {
413
+ current = await readFile(path, 'utf8');
414
+ }
415
+ catch (error) {
416
+ // Only "missing" maps to null; a real read failure surfaces so an unreadable store is
417
+ // never treated as empty and overwritten.
418
+ if (isMissing(error))
419
+ current = null;
420
+ else
421
+ throw error;
422
+ }
423
+ const next = await task(current);
424
+ if (next === null) {
425
+ await rm(path, { force: true });
426
+ return;
427
+ }
428
+ // A byte-identical result is a no-op: no tmp/rename churn and no misleading "written".
429
+ if (next === current)
430
+ return;
431
+ const tmp = await writeDurableTmp(path, next);
432
+ await commitTmp(tmp, path);
433
+ });
434
+ },
435
+ async remove(path) {
436
+ await rm(path, { recursive: true, force: true });
437
+ },
438
+ async list(path) {
439
+ // "Missing" reads as empty; any other failure (EACCES/EIO) surfaces so a
440
+ // caller never mistakes an unreadable tree for an empty one (rc.50 P2-4).
441
+ try {
442
+ return await readdir(path);
443
+ }
444
+ catch (error) {
445
+ if (isMissing(error))
446
+ return [];
447
+ throw error;
448
+ }
449
+ },
450
+ async exists(path) {
451
+ try {
452
+ await stat(path);
453
+ return true;
454
+ }
455
+ catch (error) {
456
+ if (isMissing(error))
457
+ return false;
458
+ throw error;
459
+ }
460
+ },
461
+ async rename(path, destination) {
462
+ await mkdir(dirname(destination), { recursive: true });
463
+ // P2-6 (v11): the seam's rename routes through the SAME transient
464
+ // EPERM/EBUSY retry as the writeText/transact commit path (C-28) —
465
+ // legacy retirement and archive/restore moves were bare-rename and
466
+ // could fail on antivirus/indexer hold while the commit path retried.
467
+ await renameWithRetry(path, destination);
468
+ },
469
+ async copy(path, destination) {
470
+ await mkdir(dirname(destination), { recursive: true });
471
+ await cp(path, destination, { recursive: true, force: true });
472
+ },
473
+ async size(path) {
474
+ try {
475
+ return (await stat(path)).size;
476
+ }
477
+ catch (error) {
478
+ if (isMissing(error))
479
+ return null;
480
+ throw error;
481
+ }
482
+ },
483
+ async isSymlink(path) {
484
+ try {
485
+ return (await lstat(path)).isSymbolicLink();
486
+ }
487
+ catch (error) {
488
+ // G7 guard (P3-8, v14): `null` means "probe not applicable", which for
489
+ // this guard is exactly a MISSING path. A real lstat failure
490
+ // (EACCES/EIO/…) must not read as "not a symlink" — the caller would
491
+ // then move a link it was supposed to refuse. Same discipline as the
492
+ // sibling probes (`size`/`mtime`/`exists`).
493
+ if (isMissing(error))
494
+ return null;
495
+ throw error;
496
+ }
497
+ },
498
+ async mtime(path) {
499
+ try {
500
+ return (await stat(path)).mtimeMs;
501
+ }
502
+ catch (error) {
503
+ if (isMissing(error))
504
+ return null;
505
+ throw error;
506
+ }
507
+ },
508
+ };
509
+ }
510
+ const defaultFs = {
511
+ writeFileSync: (path, data) => { writeFileSync(path, data); },
512
+ existsSync: path => existsSync(path),
513
+ copyFileSync: (from, to) => { copyFileSync(from, to); },
514
+ renameSync: (from, to) => { renameSync(from, to); },
515
+ rmSync: (path, options) => { rmSync(path, options); },
516
+ // N14: only a MISSING file is "no mtime" (null). Any other stat failure
517
+ // rejects, so probeMtime() reports it as unknown instead of as absent.
518
+ mtime: (path) => {
519
+ try {
520
+ return statSync(path).mtimeMs;
521
+ }
522
+ catch (error) {
523
+ // v43 S1-4: the canonical predicate (core/probe.ts) — one definition of
524
+ // "this read failure means it is not there" instead of a second copy.
525
+ if (isMissingPath(error))
526
+ return null;
527
+ throw error;
528
+ }
529
+ },
530
+ };
531
+ /**
532
+ * S6.3 (E-40): atomically replace a set of files within one directory.
533
+ * Each file is staged to a sibling `<name>.tmp`, then renamed into place
534
+ * (atomic on the same filesystem); a pre-existing file keeps a single
535
+ * `<name>.bak`. A failure at ANY staging step removes the staged temps and
536
+ * leaves the previous files untouched — a half-written target is impossible
537
+ * up to the staging and backup phases.
538
+ *
539
+ * F-211: the rename COMMIT phase does not carry the same guarantee. When a
540
+ * filesystem refuses to overwrite the destination, the code removes it and
541
+ * retries the rename; if the retry ALSO fails the destination is gone. The
542
+ * single `.bak` created above still holds the previous content, so the target
543
+ * is restored from it (best-effort) before the original rename error is
544
+ * rethrown, so a failed commit never leaves the file missing.
545
+ *
546
+ * V4-17: the `.bak` is refreshed to the committed content after every
547
+ * successful commit, so a later failed commit always recovers to the most
548
+ * recent good generation (a once-at-first-install `.bak` could otherwise
549
+ * restore content several installs old). The recovery error names the `.bak`
550
+ * source (and mtime when available) so the caller knows the generation it
551
+ * restored from.
552
+ */
553
+ export function atomicWriteFiles(targetDir, writes, fs = defaultFs,
554
+ // F3 (P2-19, v11): warnings go through the caller's logger (the runtime
555
+ // pipeline), not a raw console.warn — the .bak-refresh failure is a
556
+ // real production event and must be observable in the operator logs.
557
+ logger = console.warn) {
558
+ const stage = (name) => join(targetDir, `${name}.tmp`);
559
+ // V6-42 (0.3.36): a duplicate name makes the commit re-visit the same
560
+ // target: the second rename ENOENTs (the tmp already moved), then the
561
+ // remove-then-rename recovery DELETES the file the first commit just
562
+ // installed and restores an old `.bak` generation — a self-referential,
563
+ // confusing failure. Fail loud on the input instead.
564
+ const dupes = [...new Set(writes.map(write => write.name).filter((name, index, all) => all.indexOf(name) !== index))];
565
+ if (dupes.length > 0) {
566
+ throw new Error(`atomicWriteFiles: duplicate input names: ${dupes.join(', ')}`);
567
+ }
568
+ try {
569
+ for (const { name, content } of writes)
570
+ fs.writeFileSync(stage(name), content);
571
+ for (const { name } of writes) {
572
+ const finalPath = join(targetDir, name);
573
+ const bakPath = join(targetDir, `${name}.bak`);
574
+ // Back up the currently installed file once, so an interrupted commit can
575
+ // fall back to the previous usable composition.
576
+ if (fs.existsSync(finalPath) && !fs.existsSync(bakPath))
577
+ fs.copyFileSync(finalPath, bakPath);
578
+ }
579
+ const committed = [];
580
+ for (const { name } of writes) {
581
+ const finalPath = join(targetDir, name);
582
+ const bakPath = join(targetDir, `${name}.bak`);
583
+ const tmp = stage(name);
584
+ try {
585
+ fs.renameSync(tmp, finalPath);
586
+ committed.push(name);
587
+ }
588
+ catch {
589
+ // Some filesystems refuse to overwrite the destination; the single
590
+ // .bak above already holds the previous file, so remove-then-rename is
591
+ // safe here.
592
+ try {
593
+ fs.rmSync(finalPath, { force: true });
594
+ }
595
+ catch (removeError) {
596
+ // V6-28 (0.3.36): a failed remove must not escape WITHOUT the
597
+ // disclosure chain — `committed` is the only trace of a partial
598
+ // install and the recovery narration would be lost (Windows: rename
599
+ // and unlink often fail together with EPERM/EBUSY).
600
+ const already = committed.length > 0 ? `; already committed: ${committed.join(', ')}` : '';
601
+ throw new Error(`atomicWriteFiles: could not remove "${name}" to replace it (${removeError instanceof Error ? removeError.message : String(removeError)})${already}`);
602
+ }
603
+ try {
604
+ fs.renameSync(tmp, finalPath);
605
+ committed.push(name);
606
+ }
607
+ catch (renameError) {
608
+ // F-211: the retry failed AFTER the target was removed — restore it
609
+ // from .bak before surfacing the error, so the file is not left
610
+ // missing. Best-effort: if the restore also fails, note that and
611
+ // throw the original rename error so the caller still knows why.
612
+ // V5-17 (0.3.31): a multi-file commit that fails midway is PARTIAL —
613
+ // the error must disclose which files already landed (the caller is
614
+ // otherwise told only about the file that failed).
615
+ const already = committed.length > 0 ? `; already committed: ${committed.join(', ')}` : '';
616
+ if (fs.existsSync(bakPath)) {
617
+ try {
618
+ fs.copyFileSync(bakPath, finalPath);
619
+ }
620
+ catch {
621
+ throw new Error(`atomicWriteFiles: "${name}" was removed but recovery from ${bakPath} failed (${renameError instanceof Error ? renameError.message : String(renameError)}); verify the file manually${already}`);
622
+ }
623
+ // V4-17: recovery succeeded — name the source generation so a caller
624
+ // is not misled into thinking the attempt actually landed.
625
+ const bakMtime = fs.mtime?.(bakPath);
626
+ const when = bakMtime !== null && bakMtime !== undefined && Number.isFinite(bakMtime) ? ` (bak mtime ${new Date(bakMtime).toISOString()})` : '';
627
+ throw new Error(`atomicWriteFiles: "${name}" was removed during commit and restored from the last good generation ${bakPath}${when}; original error: ${renameError instanceof Error ? renameError.message : String(renameError)}${already}`);
628
+ }
629
+ throw new Error(`${renameError instanceof Error ? renameError.message : String(renameError)}${already}`);
630
+ }
631
+ }
632
+ }
633
+ // V4-17: a successful commit refreshes each .bak to the content that just
634
+ // landed, so a later failed commit recovers to the most recent good
635
+ // generation rather than one several installs ago (the backup phase above
636
+ // only ever created a .bak on first install).
637
+ // V5-17 (0.3.31): the refresh is best-effort maintenance — a failure here
638
+ // must NOT turn a successful install into a reported failure, and must not
639
+ // abort mid-loop (which would split the bak pair: some fresh, some old).
640
+ for (const { name } of writes) {
641
+ const finalPath = join(targetDir, name);
642
+ try {
643
+ if (fs.existsSync(finalPath))
644
+ fs.copyFileSync(finalPath, join(targetDir, `${name}.bak`));
645
+ }
646
+ catch (refreshError) {
647
+ logger(`atomicWriteFiles: committed "${name}" but failed to refresh its .bak (${refreshError instanceof Error ? refreshError.message : String(refreshError)}); a later failed commit will recover to an OLDER generation`);
648
+ }
649
+ }
650
+ }
651
+ catch (error) {
652
+ // OPT-24 (2026-09): the cleanup loop runs INSIDE the catch — an rmSync
653
+ // that hits EPERM/EBUSY (Windows: AV indexer, open handle; force only
654
+ // suppresses ENOENT) escaped the catch and REPLACED the carefully built
655
+ // recovery diagnostic (including the already-committed disclosure), and
656
+ // left the remaining staged temps behind. Cleanup failures are now
657
+ // collected and appended to the original error; every temp gets its
658
+ // removal attempt.
659
+ const cleanupFailures = [];
660
+ for (const { name } of writes) {
661
+ try {
662
+ fs.rmSync(stage(name), { force: true, recursive: true });
663
+ }
664
+ catch (cleanupError) {
665
+ cleanupFailures.push(`${name}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
666
+ }
667
+ }
668
+ if (cleanupFailures.length > 0) {
669
+ const original = error instanceof Error ? error.message : String(error);
670
+ throw new Error(`${original} (staged-temp cleanup also failed: ${cleanupFailures.join('; ')})`);
671
+ }
672
+ throw error;
673
+ }
674
+ }
675
+ //# sourceMappingURL=io.js.map