@lmzhen/dsh-evolution-core 0.21.0 → 0.23.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/README.md +20 -0
- package/lib/index.js +412 -556
- package/lib/types/errors.d.ts +2 -1
- package/lib/types/guards.d.ts +23 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/io.d.ts +44 -103
- package/lib/types/live.d.ts +78 -0
- package/lib/types/numeric.d.ts +0 -20
- package/lib/types/params.d.ts +10 -0
- package/lib/types/platform-services.d.ts +2 -2
- package/lib/types/preset-composition.d.ts +84 -1
- package/lib/types/skill-row-cells.d.ts +2 -0
- package/lib/types/skill-store.d.ts +9 -49
- package/lib/types/write-inventory.d.ts +16 -0
- package/package.json +1 -1
- package/persisted-write-inventory.json +175 -136
package/lib/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { parse } from "yaml";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
4
5
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { scopeOf } from "@deepseek-ai/dsh-scope";
|
|
9
9
|
import { z } from "zod";
|
|
@@ -1157,6 +1157,34 @@ function bodyCost(content) {
|
|
|
1157
1157
|
};
|
|
1158
1158
|
}
|
|
1159
1159
|
//#endregion
|
|
1160
|
+
//#region lib/types/guards.js
|
|
1161
|
+
/**
|
|
1162
|
+
* The family's plain-object predicate, in ONE place (v46 G7.1 / audit A8).
|
|
1163
|
+
*
|
|
1164
|
+
* The audit found the same guard — "a non-null object that is not an array" — rewritten fifteen
|
|
1165
|
+
* times across nine packages, four times inside core itself, and with two byte-identical `asRecord`
|
|
1166
|
+
* copies in evolution-policy and evolution-threat. This module is the single definition; callers
|
|
1167
|
+
* import it instead of spelling the clause again.
|
|
1168
|
+
*
|
|
1169
|
+
* @module @lmzhen/dsh-evolution-core
|
|
1170
|
+
*/
|
|
1171
|
+
/**
|
|
1172
|
+
* Is this a non-null, non-array object (what JSON calls an object)?
|
|
1173
|
+
* @param value - the value to test.
|
|
1174
|
+
* @returns whether it is a plain object.
|
|
1175
|
+
*/
|
|
1176
|
+
function isRecord(value) {
|
|
1177
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Read a value as a record, or undefined when it is not one.
|
|
1181
|
+
* @param value - the value to read.
|
|
1182
|
+
* @returns the value typed as a record, or undefined.
|
|
1183
|
+
*/
|
|
1184
|
+
function asRecord(value) {
|
|
1185
|
+
return isRecord(value) ? value : void 0;
|
|
1186
|
+
}
|
|
1187
|
+
//#endregion
|
|
1160
1188
|
//#region lib/types/probe.js
|
|
1161
1189
|
function probePresent(value) {
|
|
1162
1190
|
return {
|
|
@@ -1356,37 +1384,6 @@ function evolutionIoAdapter(provider) {
|
|
|
1356
1384
|
}
|
|
1357
1385
|
};
|
|
1358
1386
|
}
|
|
1359
|
-
/**
|
|
1360
|
-
* F-367 (②): lock paths whose release (the finally `rm`) failed. The next write
|
|
1361
|
-
* to the same file proactively recycles our own leftover lock — the holder is
|
|
1362
|
-
* us, so a leftover is stale by definition. Module-level by design: it must
|
|
1363
|
-
* survive across `nodeEvolutionIo()` instances for the self-heal to be
|
|
1364
|
-
* effective. (Not a pure function — the cross-call state is the intent.)
|
|
1365
|
-
* V4-05: this set — not an mtime heuristic — is the ONLY signal that a
|
|
1366
|
-
* same-pid lock is a leftover rather than a live in-process task, so it is the
|
|
1367
|
-
* sole gate for the self-pid recycle branch. Exported (read-only in practice)
|
|
1368
|
-
* so `io.spec.ts` can drive the self-heal path deterministically.
|
|
1369
|
-
*/
|
|
1370
|
-
const pendingSelfCleanup = /* @__PURE__ */ new Map();
|
|
1371
|
-
/** P2-7 (v11)/P3-10 (v14): record a failed release so the next write to this
|
|
1372
|
-
* path self-heals it, keeping the map's 64-entry cap — a never-re-touched path
|
|
1373
|
-
* must not pin it, while a path whose lock still EXISTS is the last to drop
|
|
1374
|
-
* (dropping it disables the self-heal for a lock that is really there). */
|
|
1375
|
-
const recordPendingSelfCleanup = async (lock, token) => {
|
|
1376
|
-
if (pendingSelfCleanup.size >= 64) {
|
|
1377
|
-
let droppable;
|
|
1378
|
-
for (const candidate of pendingSelfCleanup.keys()) {
|
|
1379
|
-
if (candidate === lock) continue;
|
|
1380
|
-
if (await readFile(candidate, "utf8").then(() => false, () => true)) {
|
|
1381
|
-
droppable = candidate;
|
|
1382
|
-
break;
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
const victim = droppable ?? pendingSelfCleanup.keys().next().value;
|
|
1386
|
-
if (victim !== void 0) pendingSelfCleanup.delete(victim);
|
|
1387
|
-
}
|
|
1388
|
-
pendingSelfCleanup.set(lock, token);
|
|
1389
|
-
};
|
|
1390
1387
|
const RENAME_RETRY_BASE_MS = 50;
|
|
1391
1388
|
const RENAME_RETRY_MAX_DELAY_MS = 800;
|
|
1392
1389
|
const RENAME_RETRY_MAX_ATTEMPTS = 6;
|
|
@@ -1514,73 +1511,14 @@ function isProcessAlive(pid) {
|
|
|
1514
1511
|
return error?.code === "EPERM";
|
|
1515
1512
|
}
|
|
1516
1513
|
}
|
|
1517
|
-
/**
|
|
1518
|
-
*
|
|
1519
|
-
|
|
1520
|
-
* consume these two constants instead of repeating the literals. */
|
|
1521
|
-
const LOCK_SUFFIX = ".lock";
|
|
1522
|
-
/** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
|
|
1523
|
-
* torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
|
|
1524
|
-
* the pid part is intact, which is what the takeover probe needs. The capture
|
|
1525
|
-
* group feeds `parseLockBody` (v20 A-1: skill-store's sweepers consume this
|
|
1526
|
-
* helper instead of re-inlining a third regex copy — F-17 single-source). */
|
|
1527
|
-
const LOCK_BODY_RE = /^(\d+):[0-9a-f]*$/;
|
|
1528
|
-
/** Parse a writer-lock body into its holder pid. `null` when the body does
|
|
1529
|
-
* not have the `pid:token` shape at all (e.g. a user support file named
|
|
1530
|
-
* `*.lock`) — callers leave such files alone. A shape-matching body always
|
|
1531
|
-
* yields a number, possibly `0` (state corruption / hand edit): there is no
|
|
1532
|
-
* pid 0, so a `0` holder must be treated as DEAD by the caller —
|
|
1533
|
-
* `isProcessAlive(0)` signals the caller's own process group on POSIX and
|
|
1534
|
-
* answers true, so probe sites must guard `holder > 0` before probing. */
|
|
1535
|
-
function parseLockBody(body) {
|
|
1536
|
-
const match = LOCK_BODY_RE.exec(body.trim());
|
|
1537
|
-
return match === null ? null : Number(match[1]);
|
|
1538
|
-
}
|
|
1539
|
-
/**
|
|
1540
|
-
* V27 G0.2 (EVO-IO-01): the write lock named by this claim is no longer ours
|
|
1541
|
-
* at the commit point — a takeover reclaimed it while we were inside the
|
|
1542
|
-
* critical section. The lock layer converts this into a retry of the whole
|
|
1543
|
-
* read-modify-write; it must never reach a caller as a successful write.
|
|
1544
|
-
*/
|
|
1545
|
-
var LostWriteLock = class extends Error {
|
|
1546
|
-
constructor() {
|
|
1547
|
-
super("evolution-io: the write lock was reclaimed before the commit");
|
|
1548
|
-
this.name = "LostWriteLock";
|
|
1549
|
-
}
|
|
1550
|
-
};
|
|
1514
|
+
/** How long an abandoned quarantine copy is kept for operator rescue. G2 S4: hoisted to module scope
|
|
1515
|
+
* (it used to live inside `nodeEvolutionIo`, in the region the lock protocol occupied). */
|
|
1516
|
+
const CORRUPT_SWEEP_AGE_MS = 168 * 36e5;
|
|
1551
1517
|
/** V27 G1.4: every quarantine-copy name this family has ever produced —
|
|
1552
1518
|
* `<file>.corrupt` (current fixed name), `<file>.corrupt.<epoch>`, and the
|
|
1553
1519
|
* v10-05 legacy `<file>.corrupt-<epoch>-<rand>` series. A user support file
|
|
1554
1520
|
* keeps a final extension (`.corrupt-backup.md`) and therefore stays. */
|
|
1555
1521
|
const CORRUPT_COPY_RE = /\.corrupt(\.\d+|-\d+-[0-9a-z]+)?$/;
|
|
1556
|
-
/** V27 G1.1: the three takeover windows, as protocol constants at ONE place
|
|
1557
|
-
* (the inline copies that used to live in the acquisition loop, plus the dead
|
|
1558
|
-
* branch's bare `1000`, are gone). */
|
|
1559
|
-
/** A named holder that is gone, past this age, is reclaimed. Fits inside the
|
|
1560
|
-
* `lockAttempts * 50ms` retry budget so a dead holder's lock is reachable
|
|
1561
|
-
* within one budget (v19 gate arithmetic: budget >= 2 x threshold). */
|
|
1562
|
-
const DEAD_LOCK_TAKEOVER_MS = 1e3;
|
|
1563
|
-
/** No body at all: nothing attributes the lock to a holder, so this is the one
|
|
1564
|
-
* branch that cannot probe liveness — it must outlast any plausible stall
|
|
1565
|
-
* between create and body write (V27 G0.2: 1s was below what a loaded machine
|
|
1566
|
-
* actually took, which let a peer delete a live holder's lock). */
|
|
1567
|
-
const EMPTY_LOCK_TAKEOVER_MS = 3e4;
|
|
1568
|
-
/** A body with no parseable pid (crash mid-write): 1h, far above any legal hold
|
|
1569
|
-
* and far below "forever". */
|
|
1570
|
-
const LOCK_TEAR_TAKEOVER_MS = 36e5;
|
|
1571
|
-
/** A2 (audit P1-2): even a lock whose holder pid probes ALIVE is reclaimable
|
|
1572
|
-
* past this age. Liveness-by-pid cannot distinguish the original holder from
|
|
1573
|
-
* an unrelated process the OS later assigned the same pid, so the plain alive
|
|
1574
|
-
* probe let one recycled pid brick every writer of one state file forever.
|
|
1575
|
-
* No write in this family holds a lock for more than minutes (the longest is
|
|
1576
|
-
* the ~120s review window), so a day-old "alive" lock is a recycled pid, not
|
|
1577
|
-
* a live writer. */
|
|
1578
|
-
const ALIVE_LOCK_TAKEOVER_MS = 864e5;
|
|
1579
|
-
/** P2-27 (v37): the commit-point ownership re-read. A transient read failure
|
|
1580
|
-
* (EACCES/EMFILE/antivirus hold) must not abort a valid RMW, so the read is
|
|
1581
|
-
* retried in place; only a still-unreadable lock fails the attempt. */
|
|
1582
|
-
const OWNERSHIP_READ_ATTEMPTS = 3;
|
|
1583
|
-
const OWNERSHIP_READ_RETRY_MS = 20;
|
|
1584
1522
|
/**
|
|
1585
1523
|
* V27 G1.3: the error `commitTmp` throws when the rename landed but the parent
|
|
1586
1524
|
* directory fsync failed — the bytes ARE visible, only their durability is
|
|
@@ -1593,39 +1531,12 @@ function isCommittedWarning(error) {
|
|
|
1593
1531
|
return error?.committed === true;
|
|
1594
1532
|
}
|
|
1595
1533
|
/**
|
|
1596
|
-
*
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
* pid still probes alive but whose lock is older than the alive
|
|
1603
|
-
* window (A2: a recycled pid must not hold the file forever);
|
|
1604
|
-
* - `empty` no body at all: nothing attributes it to a holder, so only the
|
|
1605
|
-
* wide `emptyAfterMs` window may reclaim it;
|
|
1606
|
-
* - `corrupt` a body with no parseable pid (a crash mid-write), past the 1h
|
|
1607
|
-
* tear threshold.
|
|
1608
|
-
* The age thresholds compare against `nowMs - mtimeMs` with `>` so a lock whose
|
|
1609
|
-
* age EQUALS the threshold is not yet reclaimed (the boundary the v19 gate fix
|
|
1610
|
-
* pinned).
|
|
1611
|
-
*/
|
|
1612
|
-
function decideTakeover(probe) {
|
|
1613
|
-
const age = (probe.nowMs ?? Date.now()) - probe.mtimeMs;
|
|
1614
|
-
const holder = Number(probe.body.split(":")[0] ?? "");
|
|
1615
|
-
const namedHolder = Number.isInteger(holder) && holder > 0;
|
|
1616
|
-
if (probe.body === "") return age > (probe.emptyAfterMs ?? 3e4) ? "empty" : "none";
|
|
1617
|
-
if (!namedHolder) return age > (probe.corruptAfterMs ?? 36e5) ? "corrupt" : "none";
|
|
1618
|
-
if (probe.alive(holder)) return age > (probe.aliveAfterMs ?? 864e5) ? "dead" : "none";
|
|
1619
|
-
return age > (probe.deadAfterMs ?? 1e3) ? "dead" : "none";
|
|
1620
|
-
}
|
|
1621
|
-
/**
|
|
1622
|
-
* Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
|
|
1623
|
-
* (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
|
|
1624
|
-
* while contention TESTS on a loaded runner may raise it (e.g. 240 ≈ 12s) —
|
|
1625
|
-
* V10-06 integration: full-suite parallel load made 32-way takeover bursts
|
|
1626
|
-
* exceed the default budget and fail loud.
|
|
1627
|
-
*/
|
|
1628
|
-
function nodeEvolutionIo(lockAttempts = 40) {
|
|
1534
|
+
* Build the Node IO backend: plain fs promises, durable tmp + rename commits (crash atomicity) and
|
|
1535
|
+
* ONE concurrency guarantee of its own — writes to the same path are serialized in-process (see
|
|
1536
|
+
* `inProcessWrite`). G2 S4 removed the cross-process write lock and its takeover protocol; the
|
|
1537
|
+
* plugin path gets cross-process safety from the platform provider (`ctx.fs`).
|
|
1538
|
+
*/
|
|
1539
|
+
function nodeEvolutionIo() {
|
|
1629
1540
|
/**
|
|
1630
1541
|
* v43 S1-4: delegates to the canonical `isMissingPath` (probe.ts) — one
|
|
1631
1542
|
* definition for the whole family instead of a second copy of the same two
|
|
@@ -1640,201 +1551,34 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
1640
1551
|
/** True when the pid is alive (single source: `isProcessAlive`). */
|
|
1641
1552
|
const isAlive = isProcessAlive;
|
|
1642
1553
|
/**
|
|
1643
|
-
*
|
|
1644
|
-
*
|
|
1645
|
-
*
|
|
1646
|
-
*
|
|
1647
|
-
*
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
1650
|
-
*/
|
|
1651
|
-
const TICKET_STALE_MS = 5e3;
|
|
1652
|
-
/**
|
|
1653
|
-
* Cross-process write lock (claw `withFileLock` parity): an O_EXCL lock file
|
|
1654
|
-
* guards the atomic write. A >1s-old lock is taken over ONLY after probing
|
|
1655
|
-
* the holder pid it carries (rc.66): a LIVE holder is never stolen, so a
|
|
1656
|
-
* slow writer no longer loses its lock to a peer at the threshold (the
|
|
1657
|
-
* takeover is the only best-effort surface; the retry budget fails loud —
|
|
1658
|
-
* rc.65 — instead of ever proceeding unlocked). Budget = 40 * 50ms (~2s,
|
|
1659
|
-
* rc.69): 8-writer contention bursts on a loaded CI runner exceed 10
|
|
1660
|
-
* attempts (500ms), and a fail-loud throw was observed instead of a clean
|
|
1661
|
-
* serialization.
|
|
1662
|
-
* 0.3.17 (E-8): acquisition and the task are now SEPARATE try blocks — a
|
|
1663
|
-
* task error (win32 rename/EBUSY surfaces as EPERM) used to be mistaken for
|
|
1664
|
-
* lock contention, retried up to 40x and finally reported as
|
|
1665
|
-
* "could not acquire write lock" while the real cause was hidden.
|
|
1666
|
-
* 0.3.17 (E-8a): the takeover threshold (1000ms) now fits INSIDE the ~2s
|
|
1667
|
-
* retry budget (budget >= 2 x threshold), so a dead holder's lock is
|
|
1668
|
-
* actually recoverable within one budget instead of being arithmetically
|
|
1669
|
-
* unreachable.
|
|
1670
|
-
* 0.3.21 (F-101), V4-04: takeover re-reads the lock right before acting and
|
|
1671
|
-
* only proceeds when the content still names the dead pid — a peer that
|
|
1672
|
-
* acquired the lock after our stale probe wrote its own pid, and deleting a
|
|
1673
|
-
* LIVE lock is the double-hold (concurrent task) the probe must prevent. The
|
|
1674
|
-
* re-read is necessary but not sufficient: two peers can both pass it, so the
|
|
1675
|
-
* commit itself is an atomic rename to a unique name (only one peer's rename
|
|
1676
|
-
* can succeed; the loser's source is gone). A naive rm lets the later peer
|
|
1677
|
-
* delete the winner's freshly re-acquired live lock.
|
|
1678
|
-
* 0.3.21 (F-367), V4-05: a self-pid lock is recycled ONLY when the failed
|
|
1679
|
-
* release was recorded in pendingSelfCleanup — never on age alone, because an
|
|
1680
|
-
* mtime over 1s is indistinguishable from a long task still executing in this
|
|
1681
|
-
* process, and recycling that live lock would double-hold it. A failure to
|
|
1682
|
-
* release in finally is recorded so the next write self-heals.
|
|
1683
|
-
* V10-07 (P2-1): a third takeover shape — a TORN body (non-empty, no
|
|
1684
|
-
* parseable pid) — is taken over after a wide 1h threshold with a
|
|
1685
|
-
* console.warn; before this branch such a lock blocked every future writer
|
|
1686
|
-
* forever.
|
|
1687
|
-
* V27 G0.2 (EVO-IO-01): the CONTRACT for `task` is therefore: commit only
|
|
1688
|
-
* after calling the `assertOwned` callback it receives, immediately before the
|
|
1689
|
-
* rename (or delete) that publishes the result. A takeover may reclaim a lock
|
|
1690
|
-
* whose holder is alive — an empty body cannot be attributed to a pid, and a
|
|
1691
|
-
* peer can read a stale stat — and the holder cannot see it from the handle it
|
|
1692
|
-
* opened (on POSIX its write lands on the UNLINKED inode). With the guard, a
|
|
1693
|
-
* reclaimed claim aborts the RMW (`LostWriteLock`, converted into a retry
|
|
1694
|
-
* here) instead of overwriting the current holder's result; without it, two
|
|
1695
|
-
* writers commit and one update is silently lost.
|
|
1554
|
+
* G2 S4: the family write-lock protocol is retired. Concurrency is the PLATFORM’s job now — the
|
|
1555
|
+
* plugin path requires `ctx.fs`, whose writes are versioned — so this backend keeps exactly one
|
|
1556
|
+
* guarantee of its own: writes to the SAME path inside THIS process are serialized (a promise
|
|
1557
|
+
* chain per path: no timers, no lock files, no takeover windows). Cross-process RMW safety comes
|
|
1558
|
+
* from the provider; a standalone caller sharing a directory across processes must not rely on
|
|
1559
|
+
* this backend alone. The durable tmp + rename commit stays — crash atomicity and mutual
|
|
1560
|
+
* exclusion are different concerns.
|
|
1696
1561
|
*/
|
|
1697
|
-
const
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
if (code === "EEXIST" || code === "EPERM") {} else {
|
|
1707
|
-
await rm(lock, { force: true }).catch(() => {});
|
|
1708
|
-
throw error;
|
|
1709
|
-
}
|
|
1710
|
-
try {
|
|
1711
|
-
const st = await stat(lock);
|
|
1712
|
-
const holderRead = await readFile(lock, "utf8").then((body) => ({
|
|
1713
|
-
ok: true,
|
|
1714
|
-
body
|
|
1715
|
-
}), () => ({
|
|
1716
|
-
ok: false,
|
|
1717
|
-
body: ""
|
|
1718
|
-
}));
|
|
1719
|
-
if (!holderRead.ok) continue;
|
|
1720
|
-
const holderContent = holderRead.body;
|
|
1721
|
-
const holder = Number(holderContent.split(":")[0] ?? "");
|
|
1722
|
-
if (holder === process.pid && pendingSelfCleanup.has(lock)) {
|
|
1723
|
-
const token = pendingSelfCleanup.get(lock);
|
|
1724
|
-
if (await readFile(lock, "utf8").catch(() => "") === token) try {
|
|
1725
|
-
await rm(lock, { force: true });
|
|
1726
|
-
pendingSelfCleanup.delete(lock);
|
|
1727
|
-
} catch {}
|
|
1728
|
-
continue;
|
|
1729
|
-
}
|
|
1730
|
-
const decision = decideTakeover({
|
|
1731
|
-
body: holderContent,
|
|
1732
|
-
mtimeMs: st.mtimeMs,
|
|
1733
|
-
alive: isAlive
|
|
1734
|
-
});
|
|
1735
|
-
if (decision !== "none") {
|
|
1736
|
-
console.warn(`evolution-io: taking over write lock ${lock} (branch=stale${decision[0]?.toUpperCase()}${decision.slice(1)}, body=${JSON.stringify(holderContent)}, ageMs=${Date.now() - st.mtimeMs}, holderPid=${Number.isInteger(holder) && holder > 0 ? holder : "none"})`);
|
|
1737
|
-
const currentRead = await readFile(lock, "utf8").then((body) => ({
|
|
1738
|
-
ok: true,
|
|
1739
|
-
body
|
|
1740
|
-
}), () => ({
|
|
1741
|
-
ok: false,
|
|
1742
|
-
body: ""
|
|
1743
|
-
}));
|
|
1744
|
-
if (!currentRead.ok) continue;
|
|
1745
|
-
if (currentRead.body === holderContent) {
|
|
1746
|
-
const ticket = `${lock}.next`;
|
|
1747
|
-
try {
|
|
1748
|
-
const ticketBody = await readFile(ticket, "utf8").catch(() => "");
|
|
1749
|
-
const ticketMtime = await stat(ticket).then((s) => s.mtimeMs, () => 0);
|
|
1750
|
-
const ticketHolder = Number(ticketBody.split(":")[0] ?? "");
|
|
1751
|
-
if ((!Number.isInteger(ticketHolder) || ticketHolder <= 0 || !isAlive(ticketHolder) || Date.now() - ticketMtime > TICKET_STALE_MS) && (ticketBody !== "" || Date.now() - ticketMtime > TICKET_STALE_MS)) await rm(ticket, { force: true }).catch(() => {});
|
|
1752
|
-
} catch {}
|
|
1753
|
-
try {
|
|
1754
|
-
await writeFile(ticket, `${process.pid}:${randomBytes(4).toString("hex")}`, { flag: "wx" });
|
|
1755
|
-
} catch {
|
|
1756
|
-
continue;
|
|
1757
|
-
}
|
|
1758
|
-
const verifyRead = await readFile(lock, "utf8").then((body) => ({
|
|
1759
|
-
ok: true,
|
|
1760
|
-
body
|
|
1761
|
-
}), () => ({
|
|
1762
|
-
ok: false,
|
|
1763
|
-
body: ""
|
|
1764
|
-
}));
|
|
1765
|
-
if (!verifyRead.ok) {
|
|
1766
|
-
await rm(ticket, { force: true }).catch(() => {});
|
|
1767
|
-
continue;
|
|
1768
|
-
}
|
|
1769
|
-
const verify = verifyRead.body;
|
|
1770
|
-
if (verify === holderContent) {
|
|
1771
|
-
const verifyHolder = Number(verify.split(":")[0] ?? "");
|
|
1772
|
-
if (!(Number.isInteger(verifyHolder) && verifyHolder > 0 && isAlive(verifyHolder))) await rm(lock, { force: true }).catch(() => {});
|
|
1773
|
-
}
|
|
1774
|
-
await rm(ticket, { force: true }).catch(() => {});
|
|
1775
|
-
continue;
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
} catch {
|
|
1779
|
-
continue;
|
|
1780
|
-
}
|
|
1781
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1782
|
-
continue;
|
|
1783
|
-
}
|
|
1784
|
-
const assertOwned = async () => {
|
|
1785
|
-
let body = "";
|
|
1786
|
-
let readable = false;
|
|
1787
|
-
for (let read = 0; read < OWNERSHIP_READ_ATTEMPTS && !readable; read += 1) {
|
|
1788
|
-
if (read > 0) await new Promise((resolve) => setTimeout(resolve, OWNERSHIP_READ_RETRY_MS));
|
|
1789
|
-
const attempt = await readFile(lock, "utf8").then((value) => ({
|
|
1790
|
-
ok: true,
|
|
1791
|
-
value
|
|
1792
|
-
}), () => ({
|
|
1793
|
-
ok: false,
|
|
1794
|
-
value: ""
|
|
1795
|
-
}));
|
|
1796
|
-
if (attempt.ok) {
|
|
1797
|
-
body = attempt.value;
|
|
1798
|
-
readable = true;
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
if (!readable) {
|
|
1802
|
-
console.warn(`evolution-io: write lock ${lock} could not be read at the commit point (ownership unverified, ours: ${JSON.stringify(myClaim)}) — aborting this read-modify-write and retrying under a fresh acquisition`);
|
|
1803
|
-
throw new LostWriteLock();
|
|
1804
|
-
}
|
|
1805
|
-
if (body !== myClaim) {
|
|
1806
|
-
console.warn(`evolution-io: write lock ${lock} was reclaimed by another writer before the commit (on disk now: ${JSON.stringify(body)}, ours: ${JSON.stringify(myClaim)}) — aborting this read-modify-write and retrying under a fresh acquisition`);
|
|
1807
|
-
throw new LostWriteLock();
|
|
1808
|
-
}
|
|
1809
|
-
};
|
|
1810
|
-
try {
|
|
1811
|
-
return await task(assertOwned);
|
|
1812
|
-
} catch (error) {
|
|
1813
|
-
if (error instanceof LostWriteLock) continue;
|
|
1814
|
-
throw error;
|
|
1815
|
-
} finally {
|
|
1816
|
-
const mine = await readFile(lock, "utf8").then((body) => ({
|
|
1817
|
-
ok: true,
|
|
1818
|
-
body
|
|
1819
|
-
}), (error) => ({
|
|
1820
|
-
ok: false,
|
|
1821
|
-
missing: isMissing(error)
|
|
1822
|
-
}));
|
|
1823
|
-
if (!mine.ok) {
|
|
1824
|
-
if (!mine.missing) await recordPendingSelfCleanup(lock, myClaim);
|
|
1825
|
-
} else if (mine.body === myClaim) await rm(lock, {
|
|
1826
|
-
force: true,
|
|
1827
|
-
maxRetries: 20,
|
|
1828
|
-
retryDelay: 100
|
|
1829
|
-
}).catch(() => recordPendingSelfCleanup(lock, myClaim));
|
|
1830
|
-
}
|
|
1562
|
+
const queues = /* @__PURE__ */ new Map();
|
|
1563
|
+
const inProcessWrite = async (path, run) => {
|
|
1564
|
+
const result = (queues.get(path) ?? Promise.resolve()).then(run, run);
|
|
1565
|
+
const settled = result.then(() => {}, () => {});
|
|
1566
|
+
queues.set(path, settled);
|
|
1567
|
+
try {
|
|
1568
|
+
return await result;
|
|
1569
|
+
} finally {
|
|
1570
|
+
if (queues.get(path) === settled) queues.delete(path);
|
|
1831
1571
|
}
|
|
1832
|
-
throw new Error(`could not acquire write lock for ${path} after ${lockAttempts} attempts`);
|
|
1833
1572
|
};
|
|
1834
|
-
/**
|
|
1835
|
-
*
|
|
1836
|
-
*
|
|
1837
|
-
*
|
|
1573
|
+
/**
|
|
1574
|
+
* Sweep transient files a crashed writer left behind, lazily: once per write, for the directory
|
|
1575
|
+
* that write is about to touch.
|
|
1576
|
+
* `<target>.<pid>.<rand>.tmp` — the durable-write tmp. This process's own leftover is recycled
|
|
1577
|
+
* immediately (a live writer's tmp is a current write, not a crash artifact); a foreign tmp
|
|
1578
|
+
* needs an age past an hour AND a dead holder.
|
|
1579
|
+
* `.corrupt*` — quarantine copies past their rescue window.
|
|
1580
|
+
* G2 S4: the lock and takeover-ticket branches are gone with the write-lock protocol.
|
|
1581
|
+
*/
|
|
1838
1582
|
const sweepStaleTmps = async (path) => {
|
|
1839
1583
|
const dir = dirname(path);
|
|
1840
1584
|
const base = basename(path);
|
|
@@ -1844,12 +1588,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
1844
1588
|
} catch {
|
|
1845
1589
|
return;
|
|
1846
1590
|
}
|
|
1847
|
-
const prefix = `${base}.`;
|
|
1848
|
-
const lockName = `${base}${LOCK_SUFFIX}`;
|
|
1849
|
-
const ticketName = `${lockName}.next`;
|
|
1850
|
-
const CORRUPT_SWEEP_AGE_MS = 168 * 36e5;
|
|
1851
1591
|
for (const name of entries) {
|
|
1852
|
-
if (!name.startsWith(prefix) || name === lockName) continue;
|
|
1853
1592
|
const tmpMatch = /^(.*)\.(\d+)\.([0-9a-f]+)\.tmp$/.exec(name);
|
|
1854
1593
|
if (tmpMatch !== null && tmpMatch[1] === base) {
|
|
1855
1594
|
const tmpPath = join(dir, name);
|
|
@@ -1861,19 +1600,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
1861
1600
|
} catch {}
|
|
1862
1601
|
continue;
|
|
1863
1602
|
}
|
|
1864
|
-
if (name
|
|
1865
|
-
const ticketPath = join(dir, name);
|
|
1866
|
-
try {
|
|
1867
|
-
const body = await readFile(ticketPath, "utf8").catch(() => "");
|
|
1868
|
-
const holder = Number(body.split(":")[0] ?? "");
|
|
1869
|
-
const st = await stat(ticketPath);
|
|
1870
|
-
const dead = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
|
|
1871
|
-
const old = Date.now() - st.mtimeMs > TICKET_STALE_MS;
|
|
1872
|
-
if (dead || old) await rm(ticketPath, { force: true });
|
|
1873
|
-
} catch {}
|
|
1874
|
-
continue;
|
|
1875
|
-
}
|
|
1876
|
-
if (CORRUPT_COPY_RE.test(name)) {
|
|
1603
|
+
if (name.startsWith(base + ".") && CORRUPT_COPY_RE.test(name)) {
|
|
1877
1604
|
const corruptPath = join(dir, name);
|
|
1878
1605
|
try {
|
|
1879
1606
|
const st = await stat(corruptPath);
|
|
@@ -1893,16 +1620,14 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
1893
1620
|
},
|
|
1894
1621
|
async writeText(path, content) {
|
|
1895
1622
|
await mkdir(dirname(path), { recursive: true });
|
|
1896
|
-
await
|
|
1623
|
+
await inProcessWrite(path, async () => {
|
|
1897
1624
|
await sweepStaleTmps(path);
|
|
1898
|
-
|
|
1899
|
-
await assertOwned();
|
|
1900
|
-
await commitTmp(tmp, path);
|
|
1625
|
+
await commitTmp(await writeDurableTmp(path, content), path);
|
|
1901
1626
|
});
|
|
1902
1627
|
},
|
|
1903
1628
|
async transact(path, task) {
|
|
1904
1629
|
await mkdir(dirname(path), { recursive: true });
|
|
1905
|
-
await
|
|
1630
|
+
await inProcessWrite(path, async () => {
|
|
1906
1631
|
await sweepStaleTmps(path);
|
|
1907
1632
|
let current;
|
|
1908
1633
|
try {
|
|
@@ -1913,14 +1638,11 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
1913
1638
|
}
|
|
1914
1639
|
const next = await task(current);
|
|
1915
1640
|
if (next === null) {
|
|
1916
|
-
await assertOwned();
|
|
1917
1641
|
await rm(path, { force: true });
|
|
1918
1642
|
return;
|
|
1919
1643
|
}
|
|
1920
1644
|
if (next === current) return;
|
|
1921
|
-
|
|
1922
|
-
await assertOwned();
|
|
1923
|
-
await commitTmp(tmp, path);
|
|
1645
|
+
await commitTmp(await writeDurableTmp(path, next), path);
|
|
1924
1646
|
});
|
|
1925
1647
|
},
|
|
1926
1648
|
async remove(path) {
|
|
@@ -1983,6 +1705,121 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
1983
1705
|
}
|
|
1984
1706
|
};
|
|
1985
1707
|
}
|
|
1708
|
+
const defaultFs = {
|
|
1709
|
+
writeFileSync: (path, data) => {
|
|
1710
|
+
writeFileSync(path, data);
|
|
1711
|
+
},
|
|
1712
|
+
existsSync: (path) => existsSync(path),
|
|
1713
|
+
copyFileSync: (from, to) => {
|
|
1714
|
+
copyFileSync(from, to);
|
|
1715
|
+
},
|
|
1716
|
+
renameSync: (from, to) => {
|
|
1717
|
+
renameSync(from, to);
|
|
1718
|
+
},
|
|
1719
|
+
rmSync: (path, options) => {
|
|
1720
|
+
rmSync(path, options);
|
|
1721
|
+
},
|
|
1722
|
+
mtime: (path) => {
|
|
1723
|
+
try {
|
|
1724
|
+
return statSync(path).mtimeMs;
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
if (isMissingPath(error)) return null;
|
|
1727
|
+
throw error;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
/**
|
|
1732
|
+
* S6.3 (E-40): atomically replace a set of files within one directory.
|
|
1733
|
+
* Each file is staged to a sibling `<name>.tmp`, then renamed into place
|
|
1734
|
+
* (atomic on the same filesystem); a pre-existing file keeps a single
|
|
1735
|
+
* `<name>.bak`. A failure at ANY staging step removes the staged temps and
|
|
1736
|
+
* leaves the previous files untouched — a half-written target is impossible
|
|
1737
|
+
* up to the staging and backup phases.
|
|
1738
|
+
*
|
|
1739
|
+
* F-211: the rename COMMIT phase does not carry the same guarantee. When a
|
|
1740
|
+
* filesystem refuses to overwrite the destination, the code removes it and
|
|
1741
|
+
* retries the rename; if the retry ALSO fails the destination is gone. The
|
|
1742
|
+
* single `.bak` created above still holds the previous content, so the target
|
|
1743
|
+
* is restored from it (best-effort) before the original rename error is
|
|
1744
|
+
* rethrown, so a failed commit never leaves the file missing.
|
|
1745
|
+
*
|
|
1746
|
+
* V4-17: the `.bak` is refreshed to the committed content after every
|
|
1747
|
+
* successful commit, so a later failed commit always recovers to the most
|
|
1748
|
+
* recent good generation (a once-at-first-install `.bak` could otherwise
|
|
1749
|
+
* restore content several installs old). The recovery error names the `.bak`
|
|
1750
|
+
* source (and mtime when available) so the caller knows the generation it
|
|
1751
|
+
* restored from.
|
|
1752
|
+
*/
|
|
1753
|
+
function atomicWriteFiles(targetDir, writes, fs = defaultFs, logger = console.warn) {
|
|
1754
|
+
const stage = (name) => join(targetDir, `${name}.tmp`);
|
|
1755
|
+
const dupes = [...new Set(writes.map((write) => write.name).filter((name, index, all) => all.indexOf(name) !== index))];
|
|
1756
|
+
if (dupes.length > 0) throw new Error(`atomicWriteFiles: duplicate input names: ${dupes.join(", ")}`);
|
|
1757
|
+
try {
|
|
1758
|
+
for (const { name, content } of writes) fs.writeFileSync(stage(name), content);
|
|
1759
|
+
for (const { name } of writes) {
|
|
1760
|
+
const finalPath = join(targetDir, name);
|
|
1761
|
+
const bakPath = join(targetDir, `${name}.bak`);
|
|
1762
|
+
if (fs.existsSync(finalPath) && !fs.existsSync(bakPath)) fs.copyFileSync(finalPath, bakPath);
|
|
1763
|
+
}
|
|
1764
|
+
const committed = [];
|
|
1765
|
+
for (const { name } of writes) {
|
|
1766
|
+
const finalPath = join(targetDir, name);
|
|
1767
|
+
const bakPath = join(targetDir, `${name}.bak`);
|
|
1768
|
+
const tmp = stage(name);
|
|
1769
|
+
try {
|
|
1770
|
+
fs.renameSync(tmp, finalPath);
|
|
1771
|
+
committed.push(name);
|
|
1772
|
+
} catch {
|
|
1773
|
+
try {
|
|
1774
|
+
fs.rmSync(finalPath, { force: true });
|
|
1775
|
+
} catch (removeError) {
|
|
1776
|
+
const already = committed.length > 0 ? `; already committed: ${committed.join(", ")}` : "";
|
|
1777
|
+
throw new Error(`atomicWriteFiles: could not remove "${name}" to replace it (${removeError instanceof Error ? removeError.message : String(removeError)})${already}`);
|
|
1778
|
+
}
|
|
1779
|
+
try {
|
|
1780
|
+
fs.renameSync(tmp, finalPath);
|
|
1781
|
+
committed.push(name);
|
|
1782
|
+
} catch (renameError) {
|
|
1783
|
+
const already = committed.length > 0 ? `; already committed: ${committed.join(", ")}` : "";
|
|
1784
|
+
if (fs.existsSync(bakPath)) {
|
|
1785
|
+
try {
|
|
1786
|
+
fs.copyFileSync(bakPath, finalPath);
|
|
1787
|
+
} catch {
|
|
1788
|
+
throw new Error(`atomicWriteFiles: "${name}" was removed but recovery from ${bakPath} failed (${renameError instanceof Error ? renameError.message : String(renameError)}); verify the file manually${already}`);
|
|
1789
|
+
}
|
|
1790
|
+
const bakMtime = fs.mtime?.(bakPath);
|
|
1791
|
+
const when = bakMtime !== null && bakMtime !== void 0 && Number.isFinite(bakMtime) ? ` (bak mtime ${new Date(bakMtime).toISOString()})` : "";
|
|
1792
|
+
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}`);
|
|
1793
|
+
}
|
|
1794
|
+
throw new Error(`${renameError instanceof Error ? renameError.message : String(renameError)}${already}`);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
for (const { name } of writes) {
|
|
1799
|
+
const finalPath = join(targetDir, name);
|
|
1800
|
+
try {
|
|
1801
|
+
if (fs.existsSync(finalPath)) fs.copyFileSync(finalPath, join(targetDir, `${name}.bak`));
|
|
1802
|
+
} catch (refreshError) {
|
|
1803
|
+
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`);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
const cleanupFailures = [];
|
|
1808
|
+
for (const { name } of writes) try {
|
|
1809
|
+
fs.rmSync(stage(name), {
|
|
1810
|
+
force: true,
|
|
1811
|
+
recursive: true
|
|
1812
|
+
});
|
|
1813
|
+
} catch (cleanupError) {
|
|
1814
|
+
cleanupFailures.push(`${name}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
1815
|
+
}
|
|
1816
|
+
if (cleanupFailures.length > 0) {
|
|
1817
|
+
const original = error instanceof Error ? error.message : String(error);
|
|
1818
|
+
throw new Error(`${original} (staged-temp cleanup also failed: ${cleanupFailures.join("; ")})`);
|
|
1819
|
+
}
|
|
1820
|
+
throw error;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1986
1823
|
//#endregion
|
|
1987
1824
|
//#region lib/types/usage.js
|
|
1988
1825
|
/**
|
|
@@ -2093,7 +1930,7 @@ async function mutateUsage(root, io, task, options = {}) {
|
|
|
2093
1930
|
let parsed = null;
|
|
2094
1931
|
try {
|
|
2095
1932
|
const probe = JSON.parse(current);
|
|
2096
|
-
if (
|
|
1933
|
+
if (isRecord(probe)) parsed = probe;
|
|
2097
1934
|
} catch {
|
|
2098
1935
|
parsed = null;
|
|
2099
1936
|
}
|
|
@@ -3221,7 +3058,7 @@ async function readJsonBody(req) {
|
|
|
3221
3058
|
*/
|
|
3222
3059
|
async function readJsonObject(req) {
|
|
3223
3060
|
const body = await readJsonBody(req);
|
|
3224
|
-
return
|
|
3061
|
+
return isRecord(body) ? body : null;
|
|
3225
3062
|
}
|
|
3226
3063
|
//#endregion
|
|
3227
3064
|
//#region lib/types/env.js
|
|
@@ -3252,7 +3089,7 @@ function allowRowCollisions(env = process.env) {
|
|
|
3252
3089
|
//#endregion
|
|
3253
3090
|
//#region lib/types/errors.js
|
|
3254
3091
|
/**
|
|
3255
|
-
* THE error-code table: E-301…E-
|
|
3092
|
+
* THE error-code table: E-301…E-322 with the exact text each one emits (E-317…E-319 are the
|
|
3256
3093
|
* write-admission gates' own refusals).
|
|
3257
3094
|
*
|
|
3258
3095
|
* The codes used to be spelled out at 33 call sites across two packages, so one
|
|
@@ -3289,6 +3126,7 @@ const EVOLUTION_ERRORS = Object.freeze({
|
|
|
3289
3126
|
"e-317-skill-write-not-confirmed": "E-317: skill \"%a1%\" was not %a2% — the confirmation prompt was declined or dismissed, so nothing was written. The prompt appears on every create and bare delete; repeat the call only if the operator asks for it.",
|
|
3290
3127
|
"e-318-skill-write-without-a-read": "E-318: skill \"%a1%\" was not read in this session, so this write is refused. Read it with the `skill` tool first (a read that failed does not count), then repeat the write.",
|
|
3291
3128
|
"e-320-the-profile-home-is-not": "E-320: the profile home is not reported by this host, so the legacy settings document cannot be located. /evolution migrate reads `<profile home>/settings.yaml.imported`; run it from a profile-launched session.",
|
|
3129
|
+
"e-322-a-parameter-would-break-a": "E-322: %a1% must be at least %a2%, but this write sets it to %a3%. Nothing was written.",
|
|
3292
3130
|
"e-321-no-io-provider-is": "E-321: no evolution-io provider is mounted, so the legacy settings document cannot be read. Mount the evolution-io row (evolution-host/evolution-all) and retry.",
|
|
3293
3131
|
"e-319-skill-write-confirm-timed-out": "E-319: skill \"%a1%\" was not %a2% — the confirmation prompt went unanswered for %a3% second(s), so the write was cancelled. Nobody was there to decide: set `skillWriteConfirm` to `auto` to write without a prompt, keep `ask` to wait until someone answers, or raise `skillWriteConfirmTimeoutSeconds`."
|
|
3294
3132
|
});
|
|
@@ -3485,35 +3323,6 @@ function clampedNumber(value, fallback, opts) {
|
|
|
3485
3323
|
if (max !== void 0 && value > max) return fallback;
|
|
3486
3324
|
return value;
|
|
3487
3325
|
}
|
|
3488
|
-
/**
|
|
3489
|
-
* Clamp one numeric knob and warn ONCE per key.
|
|
3490
|
-
*
|
|
3491
|
-
* S2.9 (T3-10): `evolution-review` and `evolution-curator` each carried their own copy of this
|
|
3492
|
-
* helper — their own once-per-key set and their own message — so the two settings readers of ONE
|
|
3493
|
-
* policy row could drift apart. The logic lives here; each caller passes its own sink (and its own
|
|
3494
|
-
* package prefix). A live field is read at every use, so the warning cannot be per-read.
|
|
3495
|
-
*
|
|
3496
|
-
* @param warned - the keys this caller already warned about.
|
|
3497
|
-
* @param warn - the warning sink (each package prefixes its own name).
|
|
3498
|
-
* @param name - the config key, for the warning.
|
|
3499
|
-
* @param value - the value as supplied, or its volatile reference.
|
|
3500
|
-
* @param fallback - the package default a corrected value falls back to.
|
|
3501
|
-
* @param min - the smallest value the engine can act on.
|
|
3502
|
-
* @param max - the largest value the carrier can hold, when bounded.
|
|
3503
|
-
* @returns the value the engine may use.
|
|
3504
|
-
*/
|
|
3505
|
-
function clampOnce(warned, warn, name, value, fallback, min, max) {
|
|
3506
|
-
const current = typeof value === "object" ? value.get() : value;
|
|
3507
|
-
const result = clampedNumber(current, fallback, max === void 0 ? { min } : {
|
|
3508
|
-
min,
|
|
3509
|
-
max
|
|
3510
|
-
});
|
|
3511
|
-
if (current !== void 0 && result !== current && !warned.has(name)) {
|
|
3512
|
-
warned.add(name);
|
|
3513
|
-
warn(`${name} provided an invalid value; falling back to the default`);
|
|
3514
|
-
}
|
|
3515
|
-
return result;
|
|
3516
|
-
}
|
|
3517
3326
|
//#endregion
|
|
3518
3327
|
//#region lib/types/threats.js
|
|
3519
3328
|
/**
|
|
@@ -4561,7 +4370,7 @@ async function recordMutation(root, io, record, cap = 500) {
|
|
|
4561
4370
|
console.warn(`mutation audit record dropped: ${mutationsFile(root)} is malformed and was not overwritten`);
|
|
4562
4371
|
return current;
|
|
4563
4372
|
}
|
|
4564
|
-
if (
|
|
4373
|
+
if (isRecord(parsed)) {
|
|
4565
4374
|
const version = parsed.version;
|
|
4566
4375
|
if (typeof version === "number" && version > 1) {
|
|
4567
4376
|
console.warn(`mutation audit record dropped: ${mutationsFile(root)} declares version ${version} (newer than 1); not overwritten`);
|
|
@@ -4625,16 +4434,23 @@ function composePresetComposition(standardComposition, deltaComposition) {
|
|
|
4625
4434
|
* test can keep — the entries are now data, and a divergence is impossible.
|
|
4626
4435
|
*/
|
|
4627
4436
|
const ROW_OVERRIDES_URL = new URL("../row-overrides.json", import.meta.url);
|
|
4628
|
-
|
|
4437
|
+
/** One cache per PATH: the installer reads the same file through its own root, and a single-slot
|
|
4438
|
+
* cache would hand the second caller the first caller's bytes. */
|
|
4439
|
+
const overridesCache = /* @__PURE__ */ new Map();
|
|
4629
4440
|
/**
|
|
4630
4441
|
* Read and validate the shared override table.
|
|
4442
|
+
* G6.1/M1: the path is a PARAMETER (default: the table beside this module). The source installer
|
|
4443
|
+
* runs the generated port from `scripts/`, so its own root has to be passed in — a module-relative
|
|
4444
|
+
* read would look for `scripts/../row-overrides.json` and fail.
|
|
4445
|
+
* @param path - the table to read.
|
|
4631
4446
|
* @returns the entries, in file order. A malformed table fails LOUD: a composer
|
|
4632
4447
|
* that silently skipped an entry would ship a preset running the platform
|
|
4633
4448
|
* default while nothing downstream reported it.
|
|
4634
4449
|
*/
|
|
4635
|
-
function loadRowOverrides() {
|
|
4636
|
-
|
|
4637
|
-
|
|
4450
|
+
function loadRowOverrides(path = fileURLToPath(ROW_OVERRIDES_URL)) {
|
|
4451
|
+
const cached = overridesCache.get(path);
|
|
4452
|
+
if (cached !== void 0) return cached;
|
|
4453
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
4638
4454
|
if (!Array.isArray(parsed)) throw new Error("evolution-core: row-overrides.json must be an array of override entries");
|
|
4639
4455
|
const entries = [];
|
|
4640
4456
|
for (const [index, entry] of parsed.entries()) {
|
|
@@ -4647,7 +4463,7 @@ function loadRowOverrides() {
|
|
|
4647
4463
|
missingReason: candidate.missingReason
|
|
4648
4464
|
});
|
|
4649
4465
|
}
|
|
4650
|
-
|
|
4466
|
+
overridesCache.set(path, entries);
|
|
4651
4467
|
return entries;
|
|
4652
4468
|
}
|
|
4653
4469
|
/**
|
|
@@ -4766,7 +4582,7 @@ function composePresetRow(baseComposition, deltaComposition, identity) {
|
|
|
4766
4582
|
}
|
|
4767
4583
|
/** The patch-entry key that ADDS a row: a plain entry overrides the row with the same id, so a
|
|
4768
4584
|
* preset the platform does not ship has to arrive inside an `insert` entry
|
|
4769
|
-
* (`
|
|
4585
|
+
* (the platform's own `user-patches.spec.ts` proves the override-vs-insert split). */
|
|
4770
4586
|
const PATCH_INSERT_KEY = "insert";
|
|
4771
4587
|
/**
|
|
4772
4588
|
* Wrap a composed row as the patch entry a profile patch needs.
|
|
@@ -6440,7 +6256,7 @@ function renderLongRunTable() {
|
|
|
6440
6256
|
/**
|
|
6441
6257
|
* Every platform service the family reaches for with `ctx.get`, and what degrades without it.
|
|
6442
6258
|
*
|
|
6443
|
-
* O-2 (v46 audit): the family probes
|
|
6259
|
+
* O-2 (v46 audit): the family probes 28 platform services optionally — the platform has no
|
|
6444
6260
|
* formalized "optional dependency", so an ABSENT service is a silent downgrade and the diagnostic
|
|
6445
6261
|
* surface is the only place a reader can learn about it. `/evolution doctor` used to judge six of
|
|
6446
6262
|
* them; this table is the single source it judges the rest from, and the arch guard cross-checks it
|
|
@@ -6450,7 +6266,7 @@ function renderLongRunTable() {
|
|
|
6450
6266
|
* `client` probes run in the browser half, where the host has no view of the seats.
|
|
6451
6267
|
* @module @lmzhen/dsh-evolution-core
|
|
6452
6268
|
*/
|
|
6453
|
-
/** The
|
|
6269
|
+
/** The 28 probes, in service-name order (the guard checks this list against the tree). */
|
|
6454
6270
|
const PLATFORM_SERVICE_PROBES = Object.freeze([
|
|
6455
6271
|
{
|
|
6456
6272
|
service: "agents",
|
|
@@ -6487,6 +6303,16 @@ const PLATFORM_SERVICE_PROBES = Object.freeze([
|
|
|
6487
6303
|
feature: "every durable read and write (the IO seam)",
|
|
6488
6304
|
side: "host"
|
|
6489
6305
|
},
|
|
6306
|
+
{
|
|
6307
|
+
service: "fs",
|
|
6308
|
+
feature: "the platform filesystem the evolution-io-fs backend maps the seam onto (locks + writeText(expected) version CAS)",
|
|
6309
|
+
side: "host"
|
|
6310
|
+
},
|
|
6311
|
+
{
|
|
6312
|
+
service: "hmr",
|
|
6313
|
+
feature: "the preset install read-merge-write commit (the platform HMR lock serializes it)",
|
|
6314
|
+
side: "host"
|
|
6315
|
+
},
|
|
6490
6316
|
{
|
|
6491
6317
|
service: "evolutionMemoryBudget",
|
|
6492
6318
|
feature: "the memory-budget divergence section of this report",
|
|
@@ -6697,10 +6523,24 @@ const SERIALIZATIONS = [
|
|
|
6697
6523
|
];
|
|
6698
6524
|
/** Parse + validate the table. A malformed table throws at import: a table the
|
|
6699
6525
|
* gate cannot read must never degrade into "no declared write sites". */
|
|
6700
|
-
function
|
|
6701
|
-
if (
|
|
6526
|
+
function parseTable(raw) {
|
|
6527
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("evolution-core: persisted-write-inventory.json must be an object with { symbols, sites }");
|
|
6528
|
+
const table = raw;
|
|
6529
|
+
if (table.symbols === null || typeof table.symbols !== "object" || Array.isArray(table.symbols)) throw new Error("evolution-core: persisted-write-inventory.json needs a symbols legend (the roots every path is written against)");
|
|
6530
|
+
for (const [symbol, entry] of Object.entries(table.symbols)) {
|
|
6531
|
+
const problems = [];
|
|
6532
|
+
if (!symbol.startsWith("<") || !symbol.endsWith(">")) problems.push("symbol name (must be <name>)");
|
|
6533
|
+
if (entry === null || typeof entry !== "object") problems.push("entry");
|
|
6534
|
+
else {
|
|
6535
|
+
const symbolEntry = entry;
|
|
6536
|
+
if (typeof symbolEntry.resolver !== "string" || symbolEntry.resolver === "") problems.push("resolver");
|
|
6537
|
+
if (symbolEntry.segments !== void 0 && (!Array.isArray(symbolEntry.segments) || symbolEntry.segments.some((segment) => typeof segment !== "string"))) problems.push("segments");
|
|
6538
|
+
}
|
|
6539
|
+
if (problems.length > 0) throw new Error(`evolution-core: persisted-write-inventory.json symbol ${symbol} is malformed (missing/invalid: ${problems.join(", ")})`);
|
|
6540
|
+
}
|
|
6541
|
+
if (!Array.isArray(table.sites)) throw new Error("evolution-core: persisted-write-inventory.json needs a sites array");
|
|
6702
6542
|
const sites = [];
|
|
6703
|
-
for (const [index, entry] of
|
|
6543
|
+
for (const [index, entry] of table.sites.entries()) {
|
|
6704
6544
|
const site = entry;
|
|
6705
6545
|
const problems = [];
|
|
6706
6546
|
if (typeof site.id !== "string" || site.id === "") problems.push("id");
|
|
@@ -6723,7 +6563,10 @@ function parseSites(raw) {
|
|
|
6723
6563
|
});
|
|
6724
6564
|
}
|
|
6725
6565
|
if (new Set(sites.map((site) => site.id)).size !== sites.length) throw new Error("evolution-core: persisted-write-inventory.json declares duplicate site ids");
|
|
6726
|
-
return
|
|
6566
|
+
return {
|
|
6567
|
+
symbols: table.symbols,
|
|
6568
|
+
sites
|
|
6569
|
+
};
|
|
6727
6570
|
}
|
|
6728
6571
|
/** Instance keys held by writer services — the single source shared by the
|
|
6729
6572
|
* claim site and the inventory row that declares it (rule N20 checks the row's
|
|
@@ -6732,6 +6575,7 @@ const INSTANCE_KEYS = {
|
|
|
6732
6575
|
/** The per-home curator: report writing + the retention sweep. */
|
|
6733
6576
|
curator: "evolution-curator" };
|
|
6734
6577
|
let cachedSites;
|
|
6578
|
+
let cachedSymbols;
|
|
6735
6579
|
/**
|
|
6736
6580
|
* The declared persisted write sites, in file order.
|
|
6737
6581
|
*
|
|
@@ -6755,8 +6599,17 @@ function persistedWriteSites() {
|
|
|
6755
6599
|
const cause = error instanceof Error ? error.message : String(error);
|
|
6756
6600
|
throw new Error(`evolution-core: persisted-write-inventory.json is unreadable (${cause}) — the package asset is missing or was not shipped; the write-inventory rules cannot be evaluated without it`);
|
|
6757
6601
|
}
|
|
6758
|
-
|
|
6759
|
-
|
|
6602
|
+
const table = parseTable(JSON.parse(raw));
|
|
6603
|
+
cachedSites = table.sites;
|
|
6604
|
+
cachedSymbols = table.symbols;
|
|
6605
|
+
return table.sites;
|
|
6606
|
+
}
|
|
6607
|
+
/** The inventory's symbol legend — the roots every declared path is written against, with the
|
|
6608
|
+
* segments the gate checks against the resolver's source.
|
|
6609
|
+
* @internal no production caller — the gates read the JSON asset directly. */
|
|
6610
|
+
function writePathSymbols() {
|
|
6611
|
+
persistedWriteSites();
|
|
6612
|
+
return cachedSymbols ?? {};
|
|
6760
6613
|
}
|
|
6761
6614
|
/** Sites serialized by the per-home instance claim, with their instance keys.
|
|
6762
6615
|
* @internal S2.4 (PLAN 2026-09-16): no production caller — the arch guard
|
|
@@ -8009,6 +7862,13 @@ function versionRowCells(row) {
|
|
|
8009
7862
|
tone: "tertiary",
|
|
8010
7863
|
slot: "meta"
|
|
8011
7864
|
});
|
|
7865
|
+
if (row.hash !== void 0 && row.hash !== "") title.push({
|
|
7866
|
+
key: "hash",
|
|
7867
|
+
literal: row.hash.slice(0, 7),
|
|
7868
|
+
tone: "tertiary",
|
|
7869
|
+
slot: "meta",
|
|
7870
|
+
note: row.hash
|
|
7871
|
+
});
|
|
8012
7872
|
const meta = [];
|
|
8013
7873
|
if (row.summary !== void 0 && row.summary !== "") meta.push({
|
|
8014
7874
|
key: "summary",
|
|
@@ -8057,6 +7917,13 @@ function skillRowCells(skill) {
|
|
|
8057
7917
|
tone: "tertiary",
|
|
8058
7918
|
slot: "meta"
|
|
8059
7919
|
}];
|
|
7920
|
+
if (skill.protectedBy !== null) meta.push({
|
|
7921
|
+
key: "protectedBy",
|
|
7922
|
+
literal: skill.protectedBy,
|
|
7923
|
+
tone: "tertiary",
|
|
7924
|
+
slot: "meta",
|
|
7925
|
+
note: skill.protectedBy
|
|
7926
|
+
});
|
|
8060
7927
|
if (skill.age !== null) meta.push({
|
|
8061
7928
|
key: "age",
|
|
8062
7929
|
copy: ageCopyKey(skill.age),
|
|
@@ -9004,7 +8871,7 @@ function elapsedSince(at, nowMs) {
|
|
|
9004
8871
|
* itself in `this.serial` (archive/restoreFromArchive deliberately do not).
|
|
9005
8872
|
* 2. **Per-directory write lock** (io.ts LOCK_*) — cross-process mutual
|
|
9006
8873
|
* exclusion plus in-process crash ownership (tickets, takeover). Checked
|
|
9007
|
-
* with `
|
|
8874
|
+
* with `supportDirsUnlistable` before any destructive move (archive/restore/
|
|
9008
8875
|
* snapshot); held inside transactIo by byte writers.
|
|
9009
8876
|
* 3. **CAS baseline (`expected:`)** — any read whose bytes feed a later write
|
|
9010
8877
|
* must either live inside the serial section that commits the write, or
|
|
@@ -9241,15 +9108,6 @@ function skillDir(root, name) {
|
|
|
9241
9108
|
function markerEntryName(marker) {
|
|
9242
9109
|
return `.${marker}`;
|
|
9243
9110
|
}
|
|
9244
|
-
/** F-17 (v18): the root-level lock files a DESTRUCTIVE MOVER must treat as an
|
|
9245
|
-
* active writer (skill body + the two marker writers). Single source with
|
|
9246
|
-
* `markerEntryName`/`LOCK_SUFFIX` so a renamed marker cannot silently drop out
|
|
9247
|
-
* of the ghost-writer probe. */
|
|
9248
|
-
const MARKER_LOCK_NAMES = [
|
|
9249
|
-
`SKILL.md${LOCK_SUFFIX}`,
|
|
9250
|
-
`.pinned${LOCK_SUFFIX}`,
|
|
9251
|
-
`.hermes-managed${LOCK_SUFFIX}`
|
|
9252
|
-
];
|
|
9253
9111
|
/** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
|
|
9254
9112
|
const ARCHIVE_RETENTION_DAYS = 365;
|
|
9255
9113
|
function markerPath(dir, marker) {
|
|
@@ -9328,7 +9186,6 @@ function validateSupportPath(filePath) {
|
|
|
9328
9186
|
if (parts.length < 2) return "Provide a file name, not just a directory.";
|
|
9329
9187
|
for (const part of parts.slice(1)) {
|
|
9330
9188
|
if (!SUPPORT_FILE_NAME_RE.test(part)) return `Unsupported file name "${part}" — use lowercase letters, digits, dots, hyphens, and underscores (leading letter or digit).`;
|
|
9331
|
-
if (part.toLowerCase().endsWith(".lock")) return `Unsupported file name "${part}" — the .lock suffix is reserved for the writer-lock protocol.`;
|
|
9332
9189
|
if (part.toLowerCase().endsWith(".corrupt") || part.toLowerCase().endsWith(".tmp")) return `Unsupported file name "${part}" — the .corrupt/.tmp suffixes are reserved for the state/IO protocols.`;
|
|
9333
9190
|
const stem = part.split(".")[0]?.toLowerCase() ?? "";
|
|
9334
9191
|
if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `Unsupported file name "${part}" — a Windows reserved device name.`;
|
|
@@ -9508,16 +9365,14 @@ var SkillLibrary = class {
|
|
|
9508
9365
|
* found no SKILL.md — remove the possibly-resurrected directory ONLY when it
|
|
9509
9366
|
* holds nothing but this path's own write-lock file. The BR-5 rule from
|
|
9510
9367
|
* setPinnedCore/createCore applies unchanged: a concurrent mover can land a
|
|
9511
|
-
* full directory between the list probe and the remove, so
|
|
9512
|
-
*
|
|
9513
|
-
* away. Best-effort: a failed cleanup leaves the "not found" result
|
|
9368
|
+
* full directory between the list probe and the remove, so ANY content at all (support files, a
|
|
9369
|
+
* fresh restore) must never be recursed away. Best-effort: a failed cleanup leaves the "not found" result
|
|
9514
9370
|
* unchanged (the operator-facing ghost-dir refusal is fail-loud already).
|
|
9515
9371
|
*/
|
|
9516
9372
|
async cleanupGhostDir(skillFilePath) {
|
|
9517
9373
|
const dir = dirname(skillFilePath);
|
|
9518
|
-
const lockName = `${basename(skillFilePath)}${LOCK_SUFFIX}`;
|
|
9519
9374
|
try {
|
|
9520
|
-
if ((await this.io.list(dir)).
|
|
9375
|
+
if ((await this.io.list(dir)).length > 0) return;
|
|
9521
9376
|
await this.io.remove(dir);
|
|
9522
9377
|
} catch {}
|
|
9523
9378
|
}
|
|
@@ -10382,110 +10237,19 @@ var SkillLibrary = class {
|
|
|
10382
10237
|
* P2 (v16): the write-lock probe for the DESTRUCTIVE MOVERS (archive /
|
|
10383
10238
|
* restoreFromArchive). A byte-writer mid-flight is the ghost-generator —
|
|
10384
10239
|
* after the move its transact commit re-creates `<dir>/…` (mkdir
|
|
10385
|
-
* recursive) and the tree ends half-archived.
|
|
10386
|
-
*
|
|
10387
|
-
*
|
|
10388
|
-
*
|
|
10389
|
-
*
|
|
10390
|
-
* and restore would later move the residue back into the live root).
|
|
10391
|
-
* Coverage: `SKILL.md.lock` (update/patch of the body) plus one level of
|
|
10392
|
-
* each support dir (write_file's lock sits next to its file). Residual:
|
|
10393
|
-
* NESTED support-subdir locks and the probe→rename TOCTOU itself remain
|
|
10394
|
-
* fail-safe (renameWithRetry rides the write out; the writer's locked
|
|
10395
|
-
* re-read refuses on the moved-away file). v37 (P2-8): a residue `.lock` whose
|
|
10396
|
-
* holder is GONE is not a writer — `decideTakeover` owns that verdict.
|
|
10240
|
+
* recursive) and the tree ends half-archived.
|
|
10241
|
+
* G2 S4: the writer-lock probe that used to answer this question is retired — writes go through
|
|
10242
|
+
* the provider's versioned writes, so a writer that raced the move fails its own write loudly
|
|
10243
|
+
* instead of landing on a moved-away path. What REMAINS load-bearing here is the fail-closed
|
|
10244
|
+
* listing rule, which is about the filesystem misbehaving rather than about writers.
|
|
10397
10245
|
*/
|
|
10398
|
-
async
|
|
10399
|
-
const
|
|
10400
|
-
|
|
10401
|
-
for (const supportDir of SUPPORT_DIRS) {
|
|
10402
|
-
let entries;
|
|
10403
|
-
try {
|
|
10404
|
-
entries = await this.io.list(join(dir, supportDir));
|
|
10405
|
-
} catch {
|
|
10406
|
-
return true;
|
|
10407
|
-
}
|
|
10408
|
-
for (const entry of entries) {
|
|
10409
|
-
if (!entry.endsWith(".lock")) continue;
|
|
10410
|
-
if (await this.isWriterLock(join(dir, supportDir, entry))) return true;
|
|
10411
|
-
}
|
|
10412
|
-
}
|
|
10413
|
-
return false;
|
|
10414
|
-
}
|
|
10415
|
-
/** P2 (v17): a `*.lock` file counts as a writer lock only when its body
|
|
10416
|
-
* carries the io layer's protocol — `pid:token`, a bare pid, or an empty body
|
|
10417
|
-
* (a creator between create and body write); anything else is user content
|
|
10418
|
-
* (suffix-only matching refused archiving and deleted user files on restore).
|
|
10419
|
-
* v37 (P2-8): the verdict is `decideTakeover`'s, so the mover refuses exactly
|
|
10420
|
-
* the lock the io layer refuses to reclaim, the 30s empty window included. */
|
|
10421
|
-
async isWriterLock(lockPath) {
|
|
10422
|
-
let body;
|
|
10423
|
-
try {
|
|
10424
|
-
body = await this.io.readText(lockPath);
|
|
10246
|
+
async supportDirsUnlistable(dir) {
|
|
10247
|
+
for (const supportDir of SUPPORT_DIRS) try {
|
|
10248
|
+
await this.io.list(join(dir, supportDir));
|
|
10425
10249
|
} catch {
|
|
10426
10250
|
return true;
|
|
10427
10251
|
}
|
|
10428
|
-
|
|
10429
|
-
const trimmed = body.trim();
|
|
10430
|
-
if (trimmed !== "" && !/^\d+$/.test(trimmed) && !LOCK_BODY_RE.test(trimmed)) return false;
|
|
10431
|
-
let mtimeMs = null;
|
|
10432
|
-
try {
|
|
10433
|
-
mtimeMs = await this.io.mtime?.(lockPath) ?? null;
|
|
10434
|
-
} catch {
|
|
10435
|
-
mtimeMs = null;
|
|
10436
|
-
}
|
|
10437
|
-
return decideTakeover({
|
|
10438
|
-
body,
|
|
10439
|
-
mtimeMs: mtimeMs ?? Date.now(),
|
|
10440
|
-
alive: isProcessAlive
|
|
10441
|
-
}) === "none";
|
|
10442
|
-
}
|
|
10443
|
-
/** P2 (v16): best-effort removal of lock residue inside a RESTORED tree.
|
|
10444
|
-
* A1-2/A1-7 (v18): the sweep now covers the marker locks the probe checks
|
|
10445
|
-
* (`SKILL.md.lock`/`.pinned.lock`/`.hermes-managed.lock`) and only removes
|
|
10446
|
-
* a lock whose holder pid is NOT alive — a live writer's lock is never
|
|
10447
|
-
* stolen by the sweep. A dead-pid residue would otherwise permanently
|
|
10448
|
-
* refuse archive/restore. */
|
|
10449
|
-
async deleteStrandedLocks(dir) {
|
|
10450
|
-
for (const markerLock of MARKER_LOCK_NAMES) await this.sweepLockIfStranded(join(dir, markerLock));
|
|
10451
|
-
for (const supportDir of SUPPORT_DIRS) {
|
|
10452
|
-
let entries = [];
|
|
10453
|
-
try {
|
|
10454
|
-
entries = await this.io.list(join(dir, supportDir));
|
|
10455
|
-
} catch {
|
|
10456
|
-
continue;
|
|
10457
|
-
}
|
|
10458
|
-
for (const entry of entries) if (entry.endsWith(".lock")) await this.sweepLockIfStranded(join(dir, supportDir, entry));
|
|
10459
|
-
}
|
|
10460
|
-
}
|
|
10461
|
-
/** R2 follow-up (audit of A2): the recycled-pid window from io's
|
|
10462
|
-
* `ALIVE_LOCK_TAKEOVER_MS`, applied to the stranded-lock sweepers. A lock
|
|
10463
|
-
* whose holder pid probes alive but whose mtime is older than the alive
|
|
10464
|
-
* window is a recycled pid, not a live writer (no write in this family
|
|
10465
|
-
* holds a lock for more than minutes) — without this, one recycled pid
|
|
10466
|
-
* blocked whole-tree snapshot recovery forever with a "retry once the
|
|
10467
|
-
* write completes" message that could never become true. A backend without
|
|
10468
|
-
* `mtime` keeps the conservative refuse-on-alive posture. */
|
|
10469
|
-
async lockHolderAgedOut(lockPath) {
|
|
10470
|
-
const mtime = await this.io.mtime?.(lockPath).catch(() => null);
|
|
10471
|
-
return typeof mtime === "number" && Date.now() - mtime > 864e5;
|
|
10472
|
-
}
|
|
10473
|
-
/** Remove `lockPath` only when its body has the writer-lock `pid:token`
|
|
10474
|
-
* shape AND the holder pid is not alive (or is an aged-out recycled pid —
|
|
10475
|
-
* R2 follow-up); anything else (a user support file or a live writer's
|
|
10476
|
-
* lock) is left untouched. */
|
|
10477
|
-
async sweepLockIfStranded(lockPath) {
|
|
10478
|
-
const lockProbe = await probeText(this.io, lockPath);
|
|
10479
|
-
if (isUnknown(lockProbe)) {
|
|
10480
|
-
console.warn(`skill-store: could not read ${lockPath} to check whether its holder is alive (${lockProbe.reason}); leaving the lock in place`);
|
|
10481
|
-
return;
|
|
10482
|
-
}
|
|
10483
|
-
if (!isPresent(lockProbe)) return;
|
|
10484
|
-
const body = lockProbe.value;
|
|
10485
|
-
const pid = parseLockBody(body);
|
|
10486
|
-
if (pid === null) return;
|
|
10487
|
-
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid) && !await this.lockHolderAgedOut(lockPath)) return;
|
|
10488
|
-
await this.io.remove(lockPath).catch(() => {});
|
|
10252
|
+
return false;
|
|
10489
10253
|
}
|
|
10490
10254
|
/** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
|
|
10491
10255
|
* only a single, non-traversing path component is safe. Dotfiles
|
|
@@ -10496,22 +10260,6 @@ var SkillLibrary = class {
|
|
|
10496
10260
|
if (typeof name !== "string") return false;
|
|
10497
10261
|
return name !== "" && name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\") && basename(name) === name;
|
|
10498
10262
|
}
|
|
10499
|
-
/** A1-7 (v18): a root-level lock whose holder is alive must refuse the
|
|
10500
|
-
* restore; a dead residue is swept so a crashed writer cannot block
|
|
10501
|
-
* recovery. A non-lock body shape is left alone (user file). */
|
|
10502
|
-
async refuseLiveLockOrSweep(lockPath, label) {
|
|
10503
|
-
let body;
|
|
10504
|
-
try {
|
|
10505
|
-
body = await this.io.readText(lockPath);
|
|
10506
|
-
} catch (error) {
|
|
10507
|
-
throw new Error(`snapshot restore refused: cannot verify ${label} (${error instanceof Error ? error.message : String(error)}); a live writer may hold it`);
|
|
10508
|
-
}
|
|
10509
|
-
if (body === null) return;
|
|
10510
|
-
const pid = parseLockBody(body);
|
|
10511
|
-
if (pid === null) return;
|
|
10512
|
-
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid) && !await this.lockHolderAgedOut(lockPath)) throw new Error(`snapshot restore refused: ${label} is being written (write lock present); retry once the write completes`);
|
|
10513
|
-
await this.io.remove(lockPath).catch(() => {});
|
|
10514
|
-
}
|
|
10515
10263
|
async archive(rawName, options = {}) {
|
|
10516
10264
|
const name = rawName.trim();
|
|
10517
10265
|
const badName = this.badName(name, { allowReserved: true });
|
|
@@ -10558,9 +10306,9 @@ var SkillLibrary = class {
|
|
|
10558
10306
|
message: `Skill "${name}" is a symlink; refusing to archive it.`
|
|
10559
10307
|
};
|
|
10560
10308
|
}
|
|
10561
|
-
if (await this.
|
|
10309
|
+
if (await this.supportDirsUnlistable(dir)) return {
|
|
10562
10310
|
ok: false,
|
|
10563
|
-
message: `Skill "${name}"
|
|
10311
|
+
message: `Skill "${name}" could not be checked for writers (a support directory could not be listed); retry archiving.`
|
|
10564
10312
|
};
|
|
10565
10313
|
const commitProtection = await this.deleteProtection(name, options);
|
|
10566
10314
|
if (commitProtection) return {
|
|
@@ -11190,16 +10938,15 @@ var SkillLibrary = class {
|
|
|
11190
10938
|
message: `Archived entry "${chosen}" is a symlink; refusing to restore it.`
|
|
11191
10939
|
};
|
|
11192
10940
|
}
|
|
11193
|
-
if (await this.
|
|
10941
|
+
if (await this.supportDirsUnlistable(dest)) return {
|
|
11194
10942
|
ok: false,
|
|
11195
|
-
message: `Skill "${name}"
|
|
10943
|
+
message: `Skill "${name}" could not be checked for writers (a support directory could not be listed); retry restoring.`
|
|
11196
10944
|
};
|
|
11197
10945
|
const moveFailure = await this.moveDir(source, dest);
|
|
11198
10946
|
if (moveFailure !== void 0) return {
|
|
11199
10947
|
ok: false,
|
|
11200
10948
|
message: `Restore of "${name}" from .archive failed: ${moveFailure}`
|
|
11201
10949
|
};
|
|
11202
|
-
await this.deleteStrandedLocks(dest);
|
|
11203
10950
|
for (const marker of [".archive-reason", ".archive-name"]) if (await this.io.exists(join(dest, marker))) await this.io.remove(join(dest, marker));
|
|
11204
10951
|
const restoredProbe = await probeText(this.io, join(dest, "SKILL.md"));
|
|
11205
10952
|
if (isUnknown(restoredProbe)) console.warn(`evolution-skill-store: the restored SKILL.md of "${name}" could not be read back (${restoredProbe.reason}); the restore is recorded without after-bytes`);
|
|
@@ -11447,8 +11194,8 @@ var SkillLibrary = class {
|
|
|
11447
11194
|
const names = await listNames(this.root, this.io);
|
|
11448
11195
|
const skipped = [];
|
|
11449
11196
|
const copyable = [];
|
|
11450
|
-
for (const name of names) if (await this.
|
|
11451
|
-
console.warn(`skill-store: snapshot skipped "${name}" —
|
|
11197
|
+
for (const name of names) if (await this.supportDirsUnlistable(this.dirOf(name))) {
|
|
11198
|
+
console.warn(`skill-store: snapshot skipped "${name}" — its support directory could not be listed; the skill is recorded as skipped in the manifest`);
|
|
11452
11199
|
skipped.push(name);
|
|
11453
11200
|
} else copyable.push(name);
|
|
11454
11201
|
const copyFailure = (await Promise.allSettled(copyable.map(async (name) => {
|
|
@@ -11456,7 +11203,7 @@ var SkillLibrary = class {
|
|
|
11456
11203
|
}))).find((result) => result.status === "rejected");
|
|
11457
11204
|
if (copyFailure) throw copyFailure.reason;
|
|
11458
11205
|
const suspect = [];
|
|
11459
|
-
for (const name of copyable) if (await this.
|
|
11206
|
+
for (const name of copyable) if (await this.supportDirsUnlistable(this.dirOf(name))) suspect.push(name);
|
|
11460
11207
|
if (suspect.length > 0) {
|
|
11461
11208
|
for (const name of suspect) console.warn(`skill-store: snapshot demoted "${name}" to skipped — a write lock appeared while its copy ran; the copied bytes are suspect`);
|
|
11462
11209
|
for (const name of suspect) copyable.splice(copyable.indexOf(name), 1);
|
|
@@ -11715,14 +11462,9 @@ var SkillLibrary = class {
|
|
|
11715
11462
|
}
|
|
11716
11463
|
for (const entry of rootEntries) {
|
|
11717
11464
|
if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json" || entry === ".curator-suppressed.json" || entry === ".history") continue;
|
|
11718
|
-
if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) {
|
|
11719
|
-
await this.refuseLiveLockOrSweep(join(this.root, entry), entry);
|
|
11720
|
-
continue;
|
|
11721
|
-
}
|
|
11722
11465
|
const dir = join(this.root, entry);
|
|
11723
11466
|
if (await this.io.exists(join(dir, "SKILL.md"))) {
|
|
11724
|
-
await this.
|
|
11725
|
-
if (await this.hasWriteLock(dir)) throw new Error(`snapshot restore refused: skill "${entry}" is being written (write lock present); retry once the write completes`);
|
|
11467
|
+
if (await this.supportDirsUnlistable(dir)) throw new Error(`snapshot restore refused: skill "${entry}" is being written (write lock present); retry once the write completes`);
|
|
11726
11468
|
}
|
|
11727
11469
|
}
|
|
11728
11470
|
const restoresSuppressed = manifest.sidecars.includes(".curator-suppressed.json");
|
|
@@ -11730,7 +11472,6 @@ var SkillLibrary = class {
|
|
|
11730
11472
|
if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json") continue;
|
|
11731
11473
|
if (entry === ".history") continue;
|
|
11732
11474
|
if (entry === ".curator-suppressed.json" && restoresSuppressed) continue;
|
|
11733
|
-
if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) continue;
|
|
11734
11475
|
await this.io.remove(join(this.root, entry));
|
|
11735
11476
|
}
|
|
11736
11477
|
for (const name of manifest.skills) await this.io.copy(join(snapshotPath, name), join(this.root, name));
|
|
@@ -11742,10 +11483,7 @@ var SkillLibrary = class {
|
|
|
11742
11483
|
await this.io.copy(join(snapshotPath, ".archive"), archiveRoot);
|
|
11743
11484
|
} else if (manifest.hasArchive === false) await this.io.remove(archiveRoot);
|
|
11744
11485
|
}
|
|
11745
|
-
for (const entry of await this.io.list(this.root))
|
|
11746
|
-
if (entry.startsWith(".")) continue;
|
|
11747
|
-
await this.deleteStrandedLocks(join(this.root, entry));
|
|
11748
|
-
}
|
|
11486
|
+
for (const entry of await this.io.list(this.root)) if (entry.startsWith(".")) continue;
|
|
11749
11487
|
}
|
|
11750
11488
|
};
|
|
11751
11489
|
function newSkillLibrary(options) {
|
|
@@ -12360,6 +12098,7 @@ const PARAM_EXPOSURE = Object.freeze([
|
|
|
12360
12098
|
control: "number",
|
|
12361
12099
|
unit: "天",
|
|
12362
12100
|
values: "",
|
|
12101
|
+
relation: { atLeast: "staleAfterDays" },
|
|
12363
12102
|
card: "evolution-curator"
|
|
12364
12103
|
},
|
|
12365
12104
|
{
|
|
@@ -13001,6 +12740,23 @@ const PARAM_EXPOSURE = Object.freeze([
|
|
|
13001
12740
|
values: "",
|
|
13002
12741
|
card: "evolution-approval"
|
|
13003
12742
|
},
|
|
12743
|
+
{
|
|
12744
|
+
id: "approvalConfirm",
|
|
12745
|
+
group: "write-caps",
|
|
12746
|
+
tier: "E3",
|
|
12747
|
+
authority: "cordis",
|
|
12748
|
+
owner: "evolution-approval",
|
|
12749
|
+
applies: "live",
|
|
12750
|
+
docAnchor: "PARAMETERS.md#write-caps",
|
|
12751
|
+
summary: "What the confirmation before an APPROVE does: write straight through, ask first, or treat an unanswered prompt as a cancel. The same three words skillWriteConfirm uses for a skill write.",
|
|
12752
|
+
label: "批准前先确认",
|
|
12753
|
+
hint: "点「批准」时要不要再确认一次:直接执行 / 先确认 / 等待超时即取消。默认直接执行(不打断批量批准)。",
|
|
12754
|
+
control: "select",
|
|
12755
|
+
unit: "",
|
|
12756
|
+
values: "auto|ask|timeout",
|
|
12757
|
+
valueLabels: "直接执行|先确认|等待超时即取消",
|
|
12758
|
+
card: "evolution-approval"
|
|
12759
|
+
},
|
|
13004
12760
|
{
|
|
13005
12761
|
id: "qualityWarnThreshold",
|
|
13006
12762
|
group: "deployment",
|
|
@@ -13271,14 +13027,6 @@ function readNumberParam(carrier, id) {
|
|
|
13271
13027
|
* @module @lmzhen/dsh-evolution-core
|
|
13272
13028
|
*/
|
|
13273
13029
|
/**
|
|
13274
|
-
* Whether a parsed value is a plain mapping (a settings section).
|
|
13275
|
-
* @param value - the parsed value.
|
|
13276
|
-
* @returns true for a non-null, non-array object.
|
|
13277
|
-
*/
|
|
13278
|
-
function isSection(value) {
|
|
13279
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13280
|
-
}
|
|
13281
|
-
/**
|
|
13282
13030
|
* The key names one row may be written with: its own E3 rows, plus the deprecated
|
|
13283
13031
|
* spellings that still resolve to them. An unknown key is refused instead of written —
|
|
13284
13032
|
* the platform validates a write against the row's schema, so writing it would fail the
|
|
@@ -13309,7 +13057,7 @@ function planNamespaceMigration(documentText) {
|
|
|
13309
13057
|
foreign: [],
|
|
13310
13058
|
skipped: []
|
|
13311
13059
|
};
|
|
13312
|
-
if (!
|
|
13060
|
+
if (!isRecord(parsed)) throw new Error("the settings document is not a mapping; refusing to migrate it");
|
|
13313
13061
|
const legacy = new Map(NAMESPACE_MIGRATIONS.map((entry) => [entry.namespace, entry.rowId]));
|
|
13314
13062
|
const entries = [];
|
|
13315
13063
|
const empty = [];
|
|
@@ -13317,7 +13065,7 @@ function planNamespaceMigration(documentText) {
|
|
|
13317
13065
|
for (const [namespace, section] of Object.entries(parsed)) {
|
|
13318
13066
|
const rowId = legacy.get(namespace);
|
|
13319
13067
|
if (rowId === void 0) continue;
|
|
13320
|
-
if (!
|
|
13068
|
+
if (!isRecord(section)) throw new Error("settings section `" + namespace + "` is not a mapping; refusing to migrate it");
|
|
13321
13069
|
const allowed = writableKeys(rowId);
|
|
13322
13070
|
const values = [];
|
|
13323
13071
|
for (const [key, value] of Object.entries(section)) {
|
|
@@ -13443,6 +13191,114 @@ async function applyNamespaceMigration(plan, deps) {
|
|
|
13443
13191
|
};
|
|
13444
13192
|
}
|
|
13445
13193
|
//#endregion
|
|
13194
|
+
//#region lib/types/live.js
|
|
13195
|
+
/**
|
|
13196
|
+
* The family's ONE live-config reader.
|
|
13197
|
+
*
|
|
13198
|
+
* A config field reaches a plugin in two shapes: the platform's schema parser turns a
|
|
13199
|
+
* `.volatile()` declaration into a live reference (`{ get() }`, routed through cosmokit's
|
|
13200
|
+
* `createVolatile`), while a hand-built or pre-parse config carries the plain value. Reading a
|
|
13201
|
+
* live reference as if it were a value is SILENT (a boolean compare is simply never true, a
|
|
13202
|
+
* number folds as NaN) — so the two shapes are unwrapped in exactly one place here, and every
|
|
13203
|
+
* reader below goes through it.
|
|
13204
|
+
*
|
|
13205
|
+
* This module owns no policy: clamping semantics stay in `clampedNumber` (numeric.ts), and the
|
|
13206
|
+
* once-per-key warning text/rhythm is the one `clampOnce` has always had (it moved here so the
|
|
13207
|
+
* read happens in one place; callers import it from the package barrel, so the move is invisible).
|
|
13208
|
+
*
|
|
13209
|
+
* @module @lmzhen/dsh-evolution-core
|
|
13210
|
+
*/
|
|
13211
|
+
/**
|
|
13212
|
+
* Unwrap a live reference; return a plain value unchanged. The ONLY place that decides the shape.
|
|
13213
|
+
*
|
|
13214
|
+
* The discriminant is `typeof get === 'function'` rather than a bare `typeof value === 'object'`: a
|
|
13215
|
+
* config field may legitimately carry an object (a policy record), and calling `.get()` on that
|
|
13216
|
+
* used to throw where a plain read was meant.
|
|
13217
|
+
*
|
|
13218
|
+
* @param value - the field as supplied: live reference, plain value, or absent.
|
|
13219
|
+
* @returns the plain value, or `undefined` when the reference/field is absent.
|
|
13220
|
+
*/
|
|
13221
|
+
function unwrapLive(value) {
|
|
13222
|
+
const raw = value;
|
|
13223
|
+
if (typeof raw !== "object" || raw === null) return raw === void 0 ? void 0 : raw;
|
|
13224
|
+
const candidate = raw;
|
|
13225
|
+
return typeof candidate.get === "function" ? candidate.get.call(raw) : raw;
|
|
13226
|
+
}
|
|
13227
|
+
/**
|
|
13228
|
+
* Read a numeric knob (live or plain) and clamp it, warning ONCE per key.
|
|
13229
|
+
*
|
|
13230
|
+
* @param value - the field as supplied: live reference, plain value, or absent.
|
|
13231
|
+
* @param fallback - the package default an invalid value falls back to.
|
|
13232
|
+
* @param opts - bounds plus the optional warning triple.
|
|
13233
|
+
* @returns the value the engine may use.
|
|
13234
|
+
*/
|
|
13235
|
+
function liveNumber(value, fallback, opts = {}) {
|
|
13236
|
+
const current = unwrapLive(value);
|
|
13237
|
+
const { min, max } = opts;
|
|
13238
|
+
const result = clampedNumber(current, fallback, min === void 0 ? max === void 0 ? void 0 : { max } : max === void 0 ? { min } : {
|
|
13239
|
+
min,
|
|
13240
|
+
max
|
|
13241
|
+
});
|
|
13242
|
+
const name = opts.name;
|
|
13243
|
+
if (current !== void 0 && result !== current && name !== void 0 && opts.warn !== void 0) {
|
|
13244
|
+
const warned = opts.warned ?? /* @__PURE__ */ new Set();
|
|
13245
|
+
if (!warned.has(name)) {
|
|
13246
|
+
warned.add(name);
|
|
13247
|
+
opts.warn(`${name} provided an invalid value; falling back to the default`);
|
|
13248
|
+
}
|
|
13249
|
+
}
|
|
13250
|
+
return result;
|
|
13251
|
+
}
|
|
13252
|
+
/**
|
|
13253
|
+
* Clamp one numeric knob and warn ONCE per key — the pre-0.22 signature, kept as a thin adapter so
|
|
13254
|
+
* existing callers migrate one at a time instead of in one commit.
|
|
13255
|
+
*
|
|
13256
|
+
* @param warned - the keys this caller already warned about.
|
|
13257
|
+
* @param warn - the warning sink (each package prefixes its own name).
|
|
13258
|
+
* @param name - the config key, for the warning.
|
|
13259
|
+
* @param value - the value as supplied, or its live reference.
|
|
13260
|
+
* @param fallback - the package default a corrected value falls back to.
|
|
13261
|
+
* @param min - the smallest value the engine can act on.
|
|
13262
|
+
* @param max - the largest value the carrier can hold, when bounded.
|
|
13263
|
+
* @returns the value the engine may use.
|
|
13264
|
+
*/
|
|
13265
|
+
function clampOnce(warned, warn, name, value, fallback, min, max) {
|
|
13266
|
+
return liveNumber(value, fallback, {
|
|
13267
|
+
min,
|
|
13268
|
+
...max === void 0 ? {} : { max },
|
|
13269
|
+
warned,
|
|
13270
|
+
warn,
|
|
13271
|
+
name
|
|
13272
|
+
});
|
|
13273
|
+
}
|
|
13274
|
+
/**
|
|
13275
|
+
* Read a boolean flag (live or plain). Absent ⇒ `fallback` (default `false`).
|
|
13276
|
+
*
|
|
13277
|
+
* The boolean counterpart of {@link liveNumber}: `value === true` is never true for a live
|
|
13278
|
+
* reference, which is the silent failure this reader exists to prevent.
|
|
13279
|
+
*/
|
|
13280
|
+
function liveBool(value, fallback = false) {
|
|
13281
|
+
return unwrapLive(value) ?? fallback;
|
|
13282
|
+
}
|
|
13283
|
+
/**
|
|
13284
|
+
* Read a string (live or plain). Blank/whitespace counts as unset — the same rule `evolutionRoot` uses
|
|
13285
|
+
* for `DSH_HOME`.
|
|
13286
|
+
*/
|
|
13287
|
+
function liveString(value, fallback) {
|
|
13288
|
+
const current = unwrapLive(value)?.trim();
|
|
13289
|
+
return current === void 0 || current.length === 0 ? fallback : current;
|
|
13290
|
+
}
|
|
13291
|
+
/**
|
|
13292
|
+
* Read an enumerated string (live or plain); a value outside `allowed` falls back.
|
|
13293
|
+
*
|
|
13294
|
+
* Used for the mode-like knobs (review mode, trigger, citation policy, write-confirm mode) where a
|
|
13295
|
+
* bare `?? default` would accept a typo the schema never saw.
|
|
13296
|
+
*/
|
|
13297
|
+
function liveEnum(value, allowed, fallback) {
|
|
13298
|
+
const current = unwrapLive(value);
|
|
13299
|
+
return current !== void 0 && allowed.includes(current) ? current : fallback;
|
|
13300
|
+
}
|
|
13301
|
+
//#endregion
|
|
13446
13302
|
//#region lib/types/opt-in.js
|
|
13447
13303
|
/**
|
|
13448
13304
|
* Tool names only a session that mounted the family's MODEL rows can see.
|
|
@@ -13583,4 +13439,4 @@ function foldUntrustedLines(text, limit) {
|
|
|
13583
13439
|
return limit > 0 ? marked.slice(0, limit) : marked;
|
|
13584
13440
|
}
|
|
13585
13441
|
//#endregion
|
|
13586
|
-
export { AGENT_PRESET_PACKAGE, AGENT_PRESET_ROOT_HINT,
|
|
13442
|
+
export { AGENT_PRESET_PACKAGE, AGENT_PRESET_ROOT_HINT, AUTHORING_DESCRIPTION_BAR, AUTHORING_SPLIT_LINE_CHARS, BASELINE_ACTION, CITATION_SUPPORT_DIRS, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CONTENT_SPLIT_HINT, COST_ASCII_TOKEN_HIGH, COST_ASCII_TOKEN_LOW, COST_CJK_TOKEN_HIGH, COST_CJK_TOKEN_LOW, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEDUP_MAX_PAIR_COMPARISONS, DEDUP_SIMILARITY_THRESHOLD, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_ARCHIVE_RETENTION_POLICY, DEFAULT_CITATION_POLICY, DEFAULT_CITATION_REF_BUDGET, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_MODEL, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MEMORY_REVIEW_MODEL, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_PROBE_TIMEOUT_MS, DEFAULT_REFERENCE_REWRITE_POLICY, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_RUN_RECORDS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_MODEL, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_SKILL_VERSION_KEEP, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_SUPPORT_FILE_CHAR_POLICY, DEFAULT_SUPPORT_READ_TOOL_NAMES, DEFAULT_THREAT_MAX_SCAN_CHARS, DEFAULT_USER_CHAR_LIMIT, DISPATCH_EVENT_TYPES, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, EMPTY_PATCH_SEED, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_ERRORS, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FAMILY_SESSION_TOOL_NAMES, FORBIDDEN_CONTROL_KEYS, FUZZY_MAX_PATTERN_CHARS, FUZZY_MAX_WORK, HEALTH_STAMP_RE, HISTORY_DIR, HISTORY_INDEX_VERSION, HOOK_LINE_RE, INSTANCE_KEYS, KEEP_LINE_RE, LONG_RUNS, LOW_QUALITY_THRESHOLD, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_REQUEST_BODY_BYTES, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MAX_SUPPORT_READ_PATHS, MAX_TIMER_DELAY_MS, MEMORY_GUIDANCE_SECTION_ORDER, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, NAMESPACE_MIGRATIONS, NATIVE_CALL_EVENT, NATIVE_RESULT_EVENT, PARAM_ALIASES, PARAM_EXPOSURE, PARAM_GROUP_LABELS, PARAM_NAMESPACES, PATTERN_OVERLAP, PLATFORM_CATALOG_DESCRIPTION_DEFAULT, PLATFORM_ESTIMATE_CHARS_PER_TOKEN, PLATFORM_SERVICE_PROBES, POLICY_STAGE_DEFAULTS, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, PTC_DISPATCH_EVENT, PTC_DISPATCH_START_EVENT, QUALITY_WEIGHTS, READ_REQUIRED_ACTIONS, RESTRUCTURE_TARGET_RE, RETENTION_FEEDBACK_KEEP_RATIO, RETENTION_FEEDBACK_MIN_CHARS, RUNS_SCHEMA_VERSION, SKILLS_GUIDANCE, SKILLS_GUIDANCE_SECTION_ORDER, SKILL_ACTION_REQUIRED_FIELDS, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUMMARY_DUPLICATE_HINT_THRESHOLD, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, TEXT_DIFF_MAX_CHARS, TEXT_DIFF_MAX_LINES, THREAT_EXEMPTION_HINT, TOKEN_ASCII_WEIGHT, TOKEN_CJK_WEIGHT, ToolDispatchNormalizer, ToolDispatchPayloadError, UNTRUSTED_LINE_BREAK, UNTRUSTED_LINE_MARK, UPSTREAM_LIMIT_CHARS_PER_TOKEN, VERSION_SUMMARY_MAX_CHARS, absentHostServices, advanceReview, affinity, ageCopyKey, allowRowCollisions, anchorVerdict, appendEvolutionEvent, applyCuratorLifecycleFields, applyCuratorMetaFields, applyNamespaceMigration, applyReferenceRewrite, asRecord, assertDispatchPayload, assertSkillsRootAliasRetired, assessStructureHealth, atomicWriteFiles, authoringFeedback, basePresetPlugins, blobPath, bodyCost, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpSupportRead, bumpUse, bumpView, callingScope, canonicalWriteId, claimInstance, clampOnce, clampedNumber, classifyNamespaceMigration, clearReviewChannel, collectReadSkillNames, composePresetComposition, composePresetEntry, composePresetInsert, composePresetRow, compositionRowIds, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, contentRetentionFeedback, countDispatches, createGateSet, daysSinceIso, declaredReportStamp, denseBlockSequence, describeReferenceRewrite, dispatchKeyOf, dispatchStepOf, displayBodyOf, duplicateHeadings, elapsedSince, emptyRecord, entryTarget, errorText, evaluateThreat, eventsFile, evidenceKindIndex, evolutionActivityProjection, evolutionEventPayloadIssue, evolutionEvidenceProjection, evolutionHome, evolutionIoAdapter, evolutionReadsProjection, evolutionRoot, evolutionSignalsProjection, filterUnreadSkillOps, findDriftSignal, foldCuratorFields, foldToolDispatches, foldTurnEvent, foldTurnEvents, foldUntrustedLines, frontmatterBlock, frontmatterCatalogInvalid, fuzzyPatch, getRecord, historyIndexFile, historyRoot, identity, idleDays, initialTurnFold, installEvolutionProjections, instanceClaimKey, instanceClaimedWriteSites, instanceHolder, isAbsent, isBookkeepingFrame, isCommittedWarning, isDeprecatedParamId, isFileShapedPath, isLoopbackRequest, isMissingPath, isPresent, isProcessAlive, isProgramToolName, isRecord, isReviewChannelSession, isSkillReadToolName, isSkillToolName, isUnknown, isUnreadWrite, latestActivityAt, latestVersionAt, lifecycleCandidate, listDirectories, listEventArchives, liveBool, liveEnum, liveNumber, liveString, loadMutations, loadRowOverrides, loadSuppressedNames, loadUsage, loadVersionContent, loadVersions, makeSerialQueue, mapProbe, markAgentCreated, markReviewChannel, markerEntryName, memoryRoot, mergePresetRow, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nearDuplicateSummaries, neutralizePromptVariables, newRunRegistry, newSkillLibrary, nextHistoryIndex, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, normalizeVersionSummary, observeEvent, orderVersions, overlongLines, paramNamespace, paramRowId, paramSettingsId, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseHistoryIndex, partitionVersions, payloadText, persistedWriteSite, persistedWriteSites, pickWithPolicy, planNamespaceMigration, planReferenceRewrite, planRehoming, policyStageLimits, presetPatchText, presetRowBlock, presetRowBody, presetRowId, probeAbsent, probeDesktopScope, probeList, probeMtime, probePresent, probeReason, probeText, probeUnknown, projectionText, readDispatchSignal, readEvolutionEvents, readEvolutionTimeline, readHistoryIndex, readJsonBody, readJsonObject, readNumberParam, readParam, readingStats, recordMutation, recordVersions, redactSecrets, relatedSkillNames, releaseInstance, removePresetRow, renameWithRetry, renderCuratorReportMarkdown, renderLongRunTable, reportTime, reportsSweepLockTarget, resolveBasePresetPatch, resolveCitations, resolveExecOrigins, resolveOrigins, resolveParamId, resolveRootConfig, resolveSkillsRoot, retainEventArchives, retirementReport, reviewPrompt, rowBlockSpan, rowSiblingEnd, runsFile, saveSuppressedNames, saveUsage, scanBodyHooks, scanContentThreats, scanMemoryThreats, scanThreats, scopedProbeReport, sessionAudited, sessionEvidenceIndex, sessionLastEventTime, sessionReadNames, sessionReadSkillNames, sessionSeesFamilyTools, sessionTurnSignals, skillReadNameOf, skillRowCells, skillStateKey, skillsRoot, supportFileReadOf, suppressedFile, sweepReports, sweepReviewChannelSessions, textDiffFacts, tokenLineFor, transactIo, transactTaskGuard, trimPatternBoundaries, trimTrailingBlankLines, unhookedSupportPointers, unwrapLive, updateSuppressedNames, usageFile, usageObserved, userSetKeys, validateFrontmatter, validateRestructureTarget, valueOr, verifyPromptBundle, versionActionKind, versionRowCells, versionSummaryPrompt, versionTarget, vocabulary, writeDurableTmp, writeJson, writePathSymbols, yamlPlainScalarNeedsQuotes };
|