@lmzhen/dsh-evolution-core 0.3.81 → 0.3.83
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 +2 -2
- package/lib/index.js +813 -523
- package/lib/types/drift-signals.d.ts +3 -2
- package/lib/types/events.d.ts +2 -1
- package/lib/types/evolution-events.d.ts +24 -15
- package/lib/types/frontmatter.d.ts +172 -0
- package/lib/types/fuzzy-match.d.ts +18 -0
- package/lib/types/gates.d.ts +3 -2
- package/lib/types/instance-scope.d.ts +21 -2
- package/lib/types/io.d.ts +31 -2
- package/lib/types/limits.d.ts +15 -0
- package/lib/types/memory-store.d.ts +4 -0
- package/lib/types/opt-in.d.ts +34 -4
- package/lib/types/quality.d.ts +26 -1
- package/lib/types/scope.d.ts +0 -3
- package/lib/types/skill-health.d.ts +3 -2
- package/lib/types/skill-store.d.ts +15 -157
- package/lib/types/state-store.d.ts +15 -9
- package/lib/types/threats.d.ts +3 -2
- package/lib/types/tool-dispatch.d.ts +12 -2
- package/lib/types/usage.d.ts +19 -1
- package/lib/types/write-inventory.d.ts +23 -5
- package/package.json +2 -3
- package/persisted-write-inventory.json +3 -4
package/lib/index.js
CHANGED
|
@@ -1,11 +1,84 @@
|
|
|
1
|
-
import { basename, dirname,
|
|
1
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
2
2
|
import { cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { scopeOf } from "@deepseek-ai/dsh-scope";
|
|
8
|
-
import {
|
|
8
|
+
import { parse } from "yaml";
|
|
9
|
+
//#region lib/types/probe.js
|
|
10
|
+
function probePresent(value) {
|
|
11
|
+
return {
|
|
12
|
+
kind: "present",
|
|
13
|
+
value
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function probeAbsent() {
|
|
17
|
+
return { kind: "absent" };
|
|
18
|
+
}
|
|
19
|
+
function probeUnknown(reason) {
|
|
20
|
+
return {
|
|
21
|
+
kind: "unknown",
|
|
22
|
+
reason
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function isPresent(probe) {
|
|
26
|
+
return probe.kind === "present";
|
|
27
|
+
}
|
|
28
|
+
function isAbsent(probe) {
|
|
29
|
+
return probe.kind === "absent";
|
|
30
|
+
}
|
|
31
|
+
function isUnknown(probe) {
|
|
32
|
+
return probe.kind === "unknown";
|
|
33
|
+
}
|
|
34
|
+
/** Only a PRESENT probe yields a value; absent and unknown both fall back. */
|
|
35
|
+
function valueOr(probe, fallback) {
|
|
36
|
+
return probe.kind === "present" ? probe.value : fallback;
|
|
37
|
+
}
|
|
38
|
+
function mapProbe(probe, transform) {
|
|
39
|
+
return probe.kind === "present" ? probePresent(transform(probe.value)) : probe;
|
|
40
|
+
}
|
|
41
|
+
/** The reason an `unknown` probe carries (message, never a bare String(object)). */
|
|
42
|
+
function probeReason(error) {
|
|
43
|
+
return error instanceof Error ? error.message : String(error);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* ENOENT/ENOTDIR are the ONE read failure that means "it is not there"; every
|
|
47
|
+
* other failure is an IO error and stays unknown (same split as the node
|
|
48
|
+
* backend's own isMissing, V8-23⑨ for the size probe).
|
|
49
|
+
*/
|
|
50
|
+
function isMissingPath(error) {
|
|
51
|
+
const code = error?.code;
|
|
52
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Three-state directory listing. The node backend already answers `[]` for a
|
|
56
|
+
* MISSING directory (its own rc.50 P2-4 contract) and THROWS for an unreadable
|
|
57
|
+
* one, so the value this adds is the second half: a backend that throws ENOENT
|
|
58
|
+
* reads as absent, an EACCES/EIO reads as unknown — where a bare
|
|
59
|
+
* `catch { return [] }` served a broken store as an empty one (the N14 class).
|
|
60
|
+
*/
|
|
61
|
+
async function probeList(io, dir) {
|
|
62
|
+
try {
|
|
63
|
+
return probePresent(await io.list(dir));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return isMissingPath(error) ? probeAbsent() : probeUnknown(probeReason(error));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Three-state mtime read. Absent covers both "the path is missing" and "this
|
|
70
|
+
* backend has no mtime probe" — the seam cannot tell those apart, so consumers
|
|
71
|
+
* that must know say so in their own log line. A stat that FAILS is unknown.
|
|
72
|
+
*/
|
|
73
|
+
async function probeMtime(io, path) {
|
|
74
|
+
try {
|
|
75
|
+
const value = await io.mtime?.(path) ?? null;
|
|
76
|
+
return value === null ? probeAbsent() : probePresent(value);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return probeUnknown(probeReason(error));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
9
82
|
//#region lib/types/io.js
|
|
10
83
|
/**
|
|
11
84
|
* Structural IO seam for the evolution plugin family.
|
|
@@ -40,6 +113,21 @@ async function transactIo(io, path, task) {
|
|
|
40
113
|
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)}`);
|
|
41
114
|
}
|
|
42
115
|
}
|
|
116
|
+
/** Build a {@link TransactTaskGuard} for one write path. See its doc. */
|
|
117
|
+
function transactTaskGuard(what) {
|
|
118
|
+
let invoked = false;
|
|
119
|
+
return {
|
|
120
|
+
wrap: (task) => (current) => {
|
|
121
|
+
invoked = true;
|
|
122
|
+
return task(current);
|
|
123
|
+
},
|
|
124
|
+
invoked: () => invoked,
|
|
125
|
+
assertInvoked: () => {
|
|
126
|
+
if (invoked) return;
|
|
127
|
+
throw new Error(`internal error: the write transaction for ${what} did not invoke the task; no write was performed`);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
43
131
|
/** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
|
|
44
132
|
function evolutionIoAdapter(provider) {
|
|
45
133
|
return {
|
|
@@ -324,10 +412,17 @@ function decideTakeover(probe) {
|
|
|
324
412
|
* exceed the default budget and fail loud.
|
|
325
413
|
*/
|
|
326
414
|
function nodeEvolutionIo(lockAttempts = 40) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
415
|
+
/**
|
|
416
|
+
* v43 S1-4: delegates to the canonical `isMissingPath` (probe.ts) — one
|
|
417
|
+
* definition for the whole family instead of a second copy of the same two
|
|
418
|
+
* codes. EISDIR deliberately stays OUT of that predicate: a directory
|
|
419
|
+
* squatting on a file path is not "absent" — rotation and event reads must
|
|
420
|
+
* still see it as malformed (rc.72 G-2), while the SkillLibrary.read boundary
|
|
421
|
+
* absorbs EISDIR into "absent" for its own surface (E-43).
|
|
422
|
+
* @param error - the caught read failure.
|
|
423
|
+
* @returns true only for ENOENT/ENOTDIR.
|
|
424
|
+
*/
|
|
425
|
+
const isMissing = (error) => isMissingPath(error);
|
|
331
426
|
/** True when the pid is alive (single source: `isProcessAlive`). */
|
|
332
427
|
const isAlive = isProcessAlive;
|
|
333
428
|
/**
|
|
@@ -425,7 +520,15 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
425
520
|
});
|
|
426
521
|
if (decision !== "none") {
|
|
427
522
|
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"})`);
|
|
428
|
-
|
|
523
|
+
const currentRead = await readFile(lock, "utf8").then((body) => ({
|
|
524
|
+
ok: true,
|
|
525
|
+
body
|
|
526
|
+
}), () => ({
|
|
527
|
+
ok: false,
|
|
528
|
+
body: ""
|
|
529
|
+
}));
|
|
530
|
+
if (!currentRead.ok) continue;
|
|
531
|
+
if (currentRead.body === holderContent) {
|
|
429
532
|
const ticket = `${lock}.next`;
|
|
430
533
|
try {
|
|
431
534
|
const ticketBody = await readFile(ticket, "utf8").catch(() => "");
|
|
@@ -509,9 +612,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
509
612
|
force: true,
|
|
510
613
|
maxRetries: 20,
|
|
511
614
|
retryDelay: 100
|
|
512
|
-
}).catch(
|
|
513
|
-
await recordPendingSelfCleanup(lock, await readFile(lock, "utf8").catch(() => ""));
|
|
514
|
-
});
|
|
615
|
+
}).catch(() => recordPendingSelfCleanup(lock, myClaim));
|
|
515
616
|
}
|
|
516
617
|
}
|
|
517
618
|
throw new Error(`could not acquire write lock for ${path} after ${lockAttempts} attempts`);
|
|
@@ -773,7 +874,9 @@ async function mutateUsage(root, io, task, options = {}) {
|
|
|
773
874
|
const record = parsed;
|
|
774
875
|
const isMalformed = (value) => value === null || typeof value !== "object" || Array.isArray(value);
|
|
775
876
|
if (typeof record.version === "number" && record.version > 1) {
|
|
776
|
-
|
|
877
|
+
const message = `usage sidecar ${usageFile(root)} carries schema version ${String(record.version)} (> this runtime) — writes stay frozen and the bytes preserved until the runtime is upgraded`;
|
|
878
|
+
if (options.onQuarantine !== void 0) options.onQuarantine(message);
|
|
879
|
+
else console.warn(message);
|
|
777
880
|
shapePreserved = true;
|
|
778
881
|
} else if (Object.values(record).some(isMalformed)) {
|
|
779
882
|
const bad = Object.keys(record).filter((key) => isMalformed(record[key]));
|
|
@@ -927,19 +1030,37 @@ function parseSuppressed(raw) {
|
|
|
927
1030
|
return /* @__PURE__ */ new Set();
|
|
928
1031
|
}
|
|
929
1032
|
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Plain wholesale write of the suppression sidecar (tests and fixture seeding).
|
|
1035
|
+
* @internal S2.4 (PLAN 2026-09-16, audit P2-31): NO production consumer
|
|
1036
|
+
* (verified by grep; production suppressions go through
|
|
1037
|
+
* {@link updateSuppressedNames}) — exported for the family's tests only. Do
|
|
1038
|
+
* not use it to write the sidecar in new code.
|
|
1039
|
+
*
|
|
1040
|
+
* The write rides the {@link transactIo} channel (the same lock the RMW
|
|
1041
|
+
* writer uses), not the former naked read → `io.writeText`: that form was an
|
|
1042
|
+
* unserialized read-modify-write — the last `saveSuppressedNames` in this file
|
|
1043
|
+
* with no lock, while its sibling `saveUsage` already carried the `@internal`
|
|
1044
|
+
* test-only note. Version discipline is unchanged (V24-09 / S2-14): a read
|
|
1045
|
+
* failure surfaces (an unreadable sidecar is never written over), a newer
|
|
1046
|
+
* on-disk schema is warned about and preserved byte-for-byte (the
|
|
1047
|
+
* byte-identical result short-circuits the write), and a malformed sidecar is
|
|
1048
|
+
* still overwritten with the v1 shape (the historical plain-writer posture).
|
|
1049
|
+
*/
|
|
930
1050
|
async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1051
|
+
await transactIo(io, suppressedFile(root), (current) => {
|
|
1052
|
+
if (current !== null) try {
|
|
1053
|
+
const parsed = JSON.parse(current);
|
|
1054
|
+
if (parsed !== null && typeof parsed.version === "number" && parsed.version > 1) {
|
|
1055
|
+
console.warn(`suppression sidecar ${suppressedFile(root)} declares version ${String(parsed.version)} (newer than 1); not overwritten`);
|
|
1056
|
+
return current;
|
|
1057
|
+
}
|
|
1058
|
+
} catch {}
|
|
1059
|
+
return JSON.stringify({
|
|
1060
|
+
version: 1,
|
|
1061
|
+
names: [...names].sort()
|
|
1062
|
+
}, null, 2);
|
|
1063
|
+
});
|
|
943
1064
|
}
|
|
944
1065
|
/**
|
|
945
1066
|
* Atomic read-modify-write on the suppression sidecar (rc.50 P2-2): `task`
|
|
@@ -1641,7 +1762,15 @@ async function rotateIfDue(io, path, events, rotateAt) {
|
|
|
1641
1762
|
const anchor = tail[0]?.seq ?? 0;
|
|
1642
1763
|
const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
|
|
1643
1764
|
let archived = head;
|
|
1644
|
-
|
|
1765
|
+
let existing;
|
|
1766
|
+
try {
|
|
1767
|
+
existing = await io.readText(archivePath);
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
return {
|
|
1770
|
+
ok: false,
|
|
1771
|
+
reason: `evolution event archive collision at ${archivePath} could not be read (${error instanceof Error ? error.message : String(error)}) and was not touched`
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1645
1774
|
if (existing !== null) {
|
|
1646
1775
|
let parsed = null;
|
|
1647
1776
|
try {
|
|
@@ -1716,18 +1845,20 @@ async function pruneCollideArchives(io, path) {
|
|
|
1716
1845
|
if (stamp && now - Number(stamp[1]) < COLLIDE_AGE_MS) continue;
|
|
1717
1846
|
if (!stamp) try {
|
|
1718
1847
|
const mtime = await io.mtime?.(full);
|
|
1719
|
-
if (typeof mtime
|
|
1848
|
+
if (typeof mtime !== "number" || now - mtime < COLLIDE_AGE_MS) continue;
|
|
1720
1849
|
} catch {
|
|
1721
1850
|
continue;
|
|
1722
1851
|
}
|
|
1723
1852
|
await io.remove(full).catch(() => {});
|
|
1724
1853
|
}
|
|
1725
1854
|
}
|
|
1726
|
-
/** Read the event log; a missing/whitespace-only file reads as empty,
|
|
1727
|
-
*
|
|
1728
|
-
*
|
|
1729
|
-
*
|
|
1730
|
-
*
|
|
1855
|
+
/** Read the event log; a missing/whitespace-only file reads as empty, corrupt
|
|
1856
|
+
* content is flagged (and refused on append). A well-formed future-version body
|
|
1857
|
+
* is v1-incompatible: it reads as EMPTY and is now flagged malformed as well
|
|
1858
|
+
* (C-events-dispatch-1, v43). F-338's own guarantees are untouched — the reader
|
|
1859
|
+
* never mis-shapes a newer format and the append path refuses it up front, so
|
|
1860
|
+
* the original bytes survive — while the flag reports what the old reader hid:
|
|
1861
|
+
* every record that body holds is dropped from this read. */
|
|
1731
1862
|
async function readEvolutionEvents(io, path) {
|
|
1732
1863
|
let raw;
|
|
1733
1864
|
try {
|
|
@@ -1746,7 +1877,7 @@ async function readEvolutionEvents(io, path) {
|
|
|
1746
1877
|
const parsed = JSON.parse(raw);
|
|
1747
1878
|
if (parsed.version !== void 0 && parsed.version !== 1) return {
|
|
1748
1879
|
events: [],
|
|
1749
|
-
malformed:
|
|
1880
|
+
malformed: true
|
|
1750
1881
|
};
|
|
1751
1882
|
if (!Array.isArray(parsed.events)) return {
|
|
1752
1883
|
events: [],
|
|
@@ -1767,8 +1898,11 @@ async function readEvolutionEvents(io, path) {
|
|
|
1767
1898
|
* Read the full timeline (rc.71): active log + all archives, merged by seq
|
|
1768
1899
|
* (active copy wins, duplicates only arise from the rotation crash window),
|
|
1769
1900
|
* sorted ascending. Per-file malformed flag as in `readEvolutionEvents`; a
|
|
1770
|
-
*
|
|
1771
|
-
*
|
|
1901
|
+
* flagged ARCHIVE (unreadable, damaged, or a future-version body this reader
|
|
1902
|
+
* cannot interpret) is SKIPPED — it never bricks the boot, the returned events
|
|
1903
|
+
* simply LACK that seq band, and `malformed` is the only signal that they do
|
|
1904
|
+
* (C-events-dispatch-1, v43: the flag is the consumer's contract; a truncated
|
|
1905
|
+
* timeline must never be folded back as if it were complete).
|
|
1772
1906
|
*/
|
|
1773
1907
|
async function readEvolutionTimeline(io, path, archives) {
|
|
1774
1908
|
const dir = dirname(path);
|
|
@@ -1869,12 +2003,14 @@ function buildLearnPrompt(userRequest) {
|
|
|
1869
2003
|
* DSH home root: `$DSH_HOME` or `~/.dsh`. Single source of the empty-string
|
|
1870
2004
|
* fallback — an EMPTY or WHITESPACE-ONLY DSH_HOME resolves to the default
|
|
1871
2005
|
* home, never to a CWD-relative path (0.3.19 W1.3, 0.3.22 F-207);
|
|
1872
|
-
* V8-06 (0.3.47) extends the guard to whitespace
|
|
1873
|
-
*
|
|
1874
|
-
*
|
|
1875
|
-
*
|
|
1876
|
-
*
|
|
1877
|
-
*
|
|
2006
|
+
* V8-06 (0.3.47) extends the guard to whitespace.
|
|
2007
|
+
* C-11 correction (S2.2, PLAN 2026-09-16): upstream `resolveDshHome`
|
|
2008
|
+
* (util/home-paths) uses `trim()` ONLY as the ADOPTION test and then uses the
|
|
2009
|
+
* RAW env value — the earlier "same trimmed source" form returned the trimmed
|
|
2010
|
+
* text, so `DSH_HOME=" /x "` produced `/x` where upstream produced the literal
|
|
2011
|
+
* padded path. The value semantics now match upstream line for line:
|
|
2012
|
+
* `const selected = configured ?? (fromEnv !== undefined &&
|
|
2013
|
+
* fromEnv.trim().length > 0 ? fromEnv : defaultDshHome())`.
|
|
1878
2014
|
* OPT-27 (2026-09, plan D5 — accepted): the v10-era "no `~` expansion, no
|
|
1879
2015
|
* resolve" divergence from upstream `resolveDshHome` is RETIRED. It became
|
|
1880
2016
|
* load-bearing when the skill-catalog shadow made "same tree as the upstream
|
|
@@ -1882,15 +2018,18 @@ function buildLearnPrompt(userRequest) {
|
|
|
1882
2018
|
* absolute `<home>/skills` while this value fed a literal `~/x` (a directory
|
|
1883
2019
|
* named `~` under the host CWD) or a CWD-relative path — split-brain skill
|
|
1884
2020
|
* trees, preset installs the platform never reads, doctor probes of a
|
|
1885
|
-
* directory nothing serves.
|
|
1886
|
-
*
|
|
1887
|
-
*
|
|
2021
|
+
* directory nothing serves.
|
|
2022
|
+
* S2.2 (PLAN 2026-09-16), final correction: the result is ALWAYS
|
|
2023
|
+
* `resolve(expandHomePath(selected))` — the earlier form returned an
|
|
2024
|
+
* already-absolute value VERBATIM, so a trailing slash or `..` segment
|
|
2025
|
+
* landed unnormalized while upstream normalizes every value. Relative
|
|
2026
|
+
* `DSH_HOME` values change landing spot; a CLEAN absolute home stays
|
|
2027
|
+
* byte-identical (resolve is a no-op on it).
|
|
1888
2028
|
*/
|
|
1889
2029
|
function evolutionRoot(env = process.env) {
|
|
1890
|
-
const
|
|
1891
|
-
const selected =
|
|
1892
|
-
|
|
1893
|
-
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
2030
|
+
const fromEnv = env.DSH_HOME;
|
|
2031
|
+
const selected = fromEnv !== void 0 && fromEnv.trim().length > 0 ? fromEnv : join(homedir(), ".dsh");
|
|
2032
|
+
return resolve(selected === "~" ? homedir() : selected.startsWith("~/") || selected.startsWith("~\\") ? join(homedir(), selected.slice(2)) : selected);
|
|
1894
2033
|
}
|
|
1895
2034
|
/** Evolution home path helper: `$DSH_HOME/evolution` for plugin-owned sidecar
|
|
1896
2035
|
* state (reports, activity store, feedback file, state-domain data). */
|
|
@@ -2076,7 +2215,7 @@ const PATTERNS = [
|
|
|
2076
2215
|
label: "read_secrets",
|
|
2077
2216
|
category: "exfiltration",
|
|
2078
2217
|
scope: "all",
|
|
2079
|
-
regex: /\bcat\s+[^\n]{0,512}(?:\.env(?!\w)|(?:\bcredentials\b)|\.netrc|\.pgpass|\.npmrc|\.pypirc)/i
|
|
2218
|
+
regex: /\bcat\s+[^\n]{0,512}(?:\.env(?!\w)(?!\.(?:[\w-]+\.)*(?:example|sample)(?=$|[\s"']))|(?:\bcredentials\b)|\.netrc|\.pgpass|\.npmrc|\.pypirc)/i
|
|
2080
2219
|
},
|
|
2081
2220
|
{
|
|
2082
2221
|
label: "ssh_backdoor",
|
|
@@ -2778,7 +2917,7 @@ var MemoryStore = class {
|
|
|
2778
2917
|
for (const [index, op] of operations.entries()) {
|
|
2779
2918
|
const position = index + 1;
|
|
2780
2919
|
if (op.action === "add") {
|
|
2781
|
-
const body = (op.facts ?? "").trim();
|
|
2920
|
+
const body = (op.facts ?? op.content ?? "").trim();
|
|
2782
2921
|
if (!body) return {
|
|
2783
2922
|
result: {
|
|
2784
2923
|
ok: false,
|
|
@@ -2857,7 +2996,7 @@ var MemoryStore = class {
|
|
|
2857
2996
|
const matchIndex = matches[0]?.matchIndex ?? -1;
|
|
2858
2997
|
if (op.action === "remove") working.splice(matchIndex, 1);
|
|
2859
2998
|
else {
|
|
2860
|
-
const body = (op.facts ?? "").trim();
|
|
2999
|
+
const body = (op.facts ?? op.content ?? "").trim();
|
|
2861
3000
|
if (!body) return {
|
|
2862
3001
|
result: {
|
|
2863
3002
|
ok: false,
|
|
@@ -3129,10 +3268,23 @@ function applyOneOverride(lines, override) {
|
|
|
3129
3268
|
if (!found) console.warn("evolution preset composition: warning — " + override.missingReason);
|
|
3130
3269
|
return lines;
|
|
3131
3270
|
}
|
|
3271
|
+
/**
|
|
3272
|
+
* Row ids of a composition fragment (line scan, no YAML library).
|
|
3273
|
+
*
|
|
3274
|
+
* PLAN S5.9 (2026-09-16, audit P2-27): the id extraction accepts INDENTED
|
|
3275
|
+
* `- id:` rows too, so a collision hidden in a nested group is still caught —
|
|
3276
|
+
* the old `^- id:` anchored at column 0 and was blind to exactly the rows an
|
|
3277
|
+
* upstream group nesting would produce. Twin of `rowIds` in
|
|
3278
|
+
* `scripts/install-layered.mjs` (installer.spec pins detection parity).
|
|
3279
|
+
* Boundary (current, deliberate): DETECTION covers nested rows, while the
|
|
3280
|
+
* override INJECTION anchors (`applyOneOverride` below) still match top-level
|
|
3281
|
+
* rows only — the injection indent contract (`^ {2}key:`) is defined against a
|
|
3282
|
+
* column-0 row.
|
|
3283
|
+
*/
|
|
3132
3284
|
function compositionRowIds(composition) {
|
|
3133
3285
|
const ids = /* @__PURE__ */ new Set();
|
|
3134
3286
|
for (const line of composition.split("\n")) {
|
|
3135
|
-
const id =
|
|
3287
|
+
const id = /^\s*- id:\s*(\S+)/.exec(line)?.[1];
|
|
3136
3288
|
if (id) ids.add(id);
|
|
3137
3289
|
}
|
|
3138
3290
|
return ids;
|
|
@@ -3602,13 +3754,29 @@ function jaccard(a, b) {
|
|
|
3602
3754
|
for (const token of a) if (b.has(token)) intersection += 1;
|
|
3603
3755
|
return intersection / (a.size + b.size - intersection);
|
|
3604
3756
|
}
|
|
3757
|
+
/** PLAN-R2 P2-8 (2026-09-16): default cap on two-name comparisons in
|
|
3758
|
+
* {@link computeDedupGroups}. A 2000-skill library is ~2M pairs; the old
|
|
3759
|
+
* unbounded two-two Jaccard ran seconds to tens of seconds per
|
|
3760
|
+
* review/`dedup_group` probe. 250k comparisons bounds that to well under a
|
|
3761
|
+
* second while staying far above every real library's pair count. Only
|
|
3762
|
+
* pairwise comparisons are budgeted — materializing a name's token set
|
|
3763
|
+
* (memoized, once per name) and the exact-hash pre-union phase are not. */
|
|
3764
|
+
const DEDUP_MAX_PAIR_COMPARISONS = 25e4;
|
|
3605
3765
|
/**
|
|
3606
3766
|
* Two-phase near-duplicate clustering: exact normalized-hash groups first,
|
|
3607
3767
|
* then token-Jaccard edges at {@link DEDUP_SIMILARITY_THRESHOLD} with a token
|
|
3608
3768
|
* ratio guard, union-find across the whole set.
|
|
3769
|
+
*
|
|
3770
|
+
* PLAN-R2 P2-8 (2026-09-16): each name's token set is materialized once
|
|
3771
|
+
* (memoized map), each pair takes an O(1) size-ratio short-circuit before the
|
|
3772
|
+
* intersection, and the pairwise loop is bounded by
|
|
3773
|
+
* `maxPairComparisons` (default {@link DEDUP_MAX_PAIR_COMPARISONS}); hitting
|
|
3774
|
+
* the budget stops the scan and `truncated: true` says so. Small libraries
|
|
3775
|
+
* (below the budget) behave exactly as the unbounded scan did.
|
|
3609
3776
|
*/
|
|
3610
3777
|
function computeDedupGroups(input) {
|
|
3611
3778
|
const threshold = input.threshold ?? .95;
|
|
3779
|
+
const maxPairComparisons = input.maxPairComparisons ?? 25e4;
|
|
3612
3780
|
const names = [...input.contents.keys()];
|
|
3613
3781
|
const hashes = /* @__PURE__ */ new Map();
|
|
3614
3782
|
for (const name of names) {
|
|
@@ -3644,12 +3812,19 @@ function computeDedupGroups(input) {
|
|
|
3644
3812
|
}
|
|
3645
3813
|
return set;
|
|
3646
3814
|
};
|
|
3647
|
-
|
|
3815
|
+
let compared = 0;
|
|
3816
|
+
let truncated = false;
|
|
3817
|
+
for (let index = 0; index < names.length && !truncated; index += 1) {
|
|
3648
3818
|
const a = names[index];
|
|
3649
3819
|
if (a === void 0) continue;
|
|
3650
3820
|
for (let other = index + 1; other < names.length; other += 1) {
|
|
3651
3821
|
const b = names[other];
|
|
3652
3822
|
if (b === void 0) continue;
|
|
3823
|
+
if (compared >= maxPairComparisons) {
|
|
3824
|
+
truncated = true;
|
|
3825
|
+
break;
|
|
3826
|
+
}
|
|
3827
|
+
compared += 1;
|
|
3653
3828
|
const [ta, tb] = [tokenSet(a), tokenSet(b)];
|
|
3654
3829
|
if (Math.max(ta.size, tb.size) / Math.max(1, Math.min(ta.size, tb.size)) > 5) continue;
|
|
3655
3830
|
if (jaccard(ta, tb) >= threshold) union(a, b);
|
|
@@ -3662,7 +3837,10 @@ function computeDedupGroups(input) {
|
|
|
3662
3837
|
if (group) group.push(name);
|
|
3663
3838
|
else groups.set(root, [name]);
|
|
3664
3839
|
}
|
|
3665
|
-
return
|
|
3840
|
+
return {
|
|
3841
|
+
groups: [...groups.values()].filter((group) => group.length > 1),
|
|
3842
|
+
truncated
|
|
3843
|
+
};
|
|
3666
3844
|
}
|
|
3667
3845
|
/**
|
|
3668
3846
|
* Prefix-cluster index over a name set (rc.67 merge heuristic, input side):
|
|
@@ -3716,7 +3894,7 @@ const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]{0,63}:\/\/[^\s:/@]+:)([^\s/@]
|
|
|
3716
3894
|
const PEM_PRIVATE_KEY_PATTERN = new RegExp(`-----BEGIN\\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\\s+)?PRIVATE\\s+KEY(?:\\s+BLOCK)?-----[\\s\\S]*?-----END\\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\\s+)?PRIVATE\\s+KEY(?:\\s+BLOCK)?-----`, "g");
|
|
3717
3895
|
const BLOCK_KEY_ONLY_LINE = /(?:^|\s)([\w-]*[_\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\-][\w-]*)?)\s*:(?:\r)?$/i;
|
|
3718
3896
|
const CREDENTIAL_KEY_RE = /(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]asswd|[Aa]pi[_-]?[Kk]ey)(?![a-z])/;
|
|
3719
|
-
const CANDIDATE_ASSIGNMENT_PATTERN = /(^|[^\w-])([\w-]+)(["']?[\t ]*[:=][\t ]*)([^\r\n]
|
|
3897
|
+
const CANDIDATE_ASSIGNMENT_PATTERN = /(^|[^\w-])([\w-]+)(["']?[\t ]*[:=][\t ]*)(?=[^\r\n])/g;
|
|
3720
3898
|
/**
|
|
3721
3899
|
* Mask credential-shaped text before it crosses a session boundary.
|
|
3722
3900
|
* @param text - the text about to be sent to a model outside this session.
|
|
@@ -3728,21 +3906,39 @@ function redactSecrets(text) {
|
|
|
3728
3906
|
for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
|
|
3729
3907
|
out = out.replace(URL_CREDENTIALS_PATTERN, (_match, lead) => `${lead ?? ""}<redacted>@`);
|
|
3730
3908
|
out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, lead, prefix, key, separator) => `${lead ?? ""}${prefix ?? ""}${key ?? ""}${separator ?? ""}<redacted>`);
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3909
|
+
const candidatePattern = new RegExp(CANDIDATE_ASSIGNMENT_PATTERN.source, "g");
|
|
3910
|
+
let candidateOut = "";
|
|
3911
|
+
let consumed = 0;
|
|
3912
|
+
for (let m = candidatePattern.exec(out); m !== null; m = candidatePattern.exec(out)) {
|
|
3913
|
+
const lead = m[1] ?? "";
|
|
3914
|
+
const key = m[2] ?? "";
|
|
3915
|
+
const separator = m[3] ?? "";
|
|
3916
|
+
if (!CREDENTIAL_KEY_RE.test(key)) continue;
|
|
3917
|
+
let valueEnd = m.index + m[0].length;
|
|
3918
|
+
while (valueEnd < out.length && out[valueEnd] !== "\n" && out[valueEnd] !== "\r") valueEnd += 1;
|
|
3919
|
+
candidateOut += out.slice(consumed, m.index) + lead + key + separator + "<redacted>";
|
|
3920
|
+
consumed = valueEnd;
|
|
3921
|
+
candidatePattern.lastIndex = valueEnd;
|
|
3922
|
+
}
|
|
3923
|
+
out = candidateOut + out.slice(consumed);
|
|
3735
3924
|
const lines = out.split("\n");
|
|
3736
3925
|
for (let i = 0; i < lines.length - 1; i++) {
|
|
3737
3926
|
const line = lines[i];
|
|
3738
3927
|
if (line === void 0) continue;
|
|
3739
3928
|
const camelKey = /^([\w-]+)\s*:(?:\r)?$/.exec(line)?.[1];
|
|
3740
3929
|
if (!(BLOCK_KEY_ONLY_LINE.test(line) || camelKey !== void 0 && CREDENTIAL_KEY_RE.test(camelKey))) continue;
|
|
3741
|
-
|
|
3930
|
+
let valueLine = -1;
|
|
3931
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
3932
|
+
if ((lines[j] ?? "").trim() === "") continue;
|
|
3933
|
+
valueLine = j;
|
|
3934
|
+
break;
|
|
3935
|
+
}
|
|
3936
|
+
if (valueLine < 0) continue;
|
|
3937
|
+
const next = lines[valueLine] ?? "";
|
|
3742
3938
|
const [, indent, value, tail] = /^([ \t]+)(\S.*?)([ \t]*)(?:\r)?$/.exec(next) ?? [];
|
|
3743
3939
|
if (indent === void 0 || value === void 0) continue;
|
|
3744
3940
|
if (value.includes("<redacted>")) continue;
|
|
3745
|
-
lines[
|
|
3941
|
+
lines[valueLine] = `${indent}<redacted>${tail ?? ""}`;
|
|
3746
3942
|
}
|
|
3747
3943
|
out = lines.join("\n");
|
|
3748
3944
|
out = out.split("\n").map((line) => {
|
|
@@ -3811,79 +4007,6 @@ function sweepReviewChannelSessions(isAlive) {
|
|
|
3811
4007
|
return removed;
|
|
3812
4008
|
}
|
|
3813
4009
|
//#endregion
|
|
3814
|
-
//#region lib/types/probe.js
|
|
3815
|
-
function probePresent(value) {
|
|
3816
|
-
return {
|
|
3817
|
-
kind: "present",
|
|
3818
|
-
value
|
|
3819
|
-
};
|
|
3820
|
-
}
|
|
3821
|
-
function probeAbsent() {
|
|
3822
|
-
return { kind: "absent" };
|
|
3823
|
-
}
|
|
3824
|
-
function probeUnknown(reason) {
|
|
3825
|
-
return {
|
|
3826
|
-
kind: "unknown",
|
|
3827
|
-
reason
|
|
3828
|
-
};
|
|
3829
|
-
}
|
|
3830
|
-
function isPresent(probe) {
|
|
3831
|
-
return probe.kind === "present";
|
|
3832
|
-
}
|
|
3833
|
-
function isAbsent(probe) {
|
|
3834
|
-
return probe.kind === "absent";
|
|
3835
|
-
}
|
|
3836
|
-
function isUnknown(probe) {
|
|
3837
|
-
return probe.kind === "unknown";
|
|
3838
|
-
}
|
|
3839
|
-
/** Only a PRESENT probe yields a value; absent and unknown both fall back. */
|
|
3840
|
-
function valueOr(probe, fallback) {
|
|
3841
|
-
return probe.kind === "present" ? probe.value : fallback;
|
|
3842
|
-
}
|
|
3843
|
-
function mapProbe(probe, transform) {
|
|
3844
|
-
return probe.kind === "present" ? probePresent(transform(probe.value)) : probe;
|
|
3845
|
-
}
|
|
3846
|
-
/** The reason an `unknown` probe carries (message, never a bare String(object)). */
|
|
3847
|
-
function probeReason(error) {
|
|
3848
|
-
return error instanceof Error ? error.message : String(error);
|
|
3849
|
-
}
|
|
3850
|
-
/**
|
|
3851
|
-
* ENOENT/ENOTDIR are the ONE read failure that means "it is not there"; every
|
|
3852
|
-
* other failure is an IO error and stays unknown (same split as the node
|
|
3853
|
-
* backend's own isMissing, V8-23⑨ for the size probe).
|
|
3854
|
-
*/
|
|
3855
|
-
function isMissingPath(error) {
|
|
3856
|
-
const code = error?.code;
|
|
3857
|
-
return code === "ENOENT" || code === "ENOTDIR";
|
|
3858
|
-
}
|
|
3859
|
-
/**
|
|
3860
|
-
* Three-state directory listing. The node backend already answers `[]` for a
|
|
3861
|
-
* MISSING directory (its own rc.50 P2-4 contract) and THROWS for an unreadable
|
|
3862
|
-
* one, so the value this adds is the second half: a backend that throws ENOENT
|
|
3863
|
-
* reads as absent, an EACCES/EIO reads as unknown — where a bare
|
|
3864
|
-
* `catch { return [] }` served a broken store as an empty one (the N14 class).
|
|
3865
|
-
*/
|
|
3866
|
-
async function probeList(io, dir) {
|
|
3867
|
-
try {
|
|
3868
|
-
return probePresent(await io.list(dir));
|
|
3869
|
-
} catch (error) {
|
|
3870
|
-
return isMissingPath(error) ? probeAbsent() : probeUnknown(probeReason(error));
|
|
3871
|
-
}
|
|
3872
|
-
}
|
|
3873
|
-
/**
|
|
3874
|
-
* Three-state mtime read. Absent covers both "the path is missing" and "this
|
|
3875
|
-
* backend has no mtime probe" — the seam cannot tell those apart, so consumers
|
|
3876
|
-
* that must know say so in their own log line. A stat that FAILS is unknown.
|
|
3877
|
-
*/
|
|
3878
|
-
async function probeMtime(io, path) {
|
|
3879
|
-
try {
|
|
3880
|
-
const value = await io.mtime?.(path) ?? null;
|
|
3881
|
-
return value === null ? probeAbsent() : probePresent(value);
|
|
3882
|
-
} catch (error) {
|
|
3883
|
-
return probeUnknown(probeReason(error));
|
|
3884
|
-
}
|
|
3885
|
-
}
|
|
3886
|
-
//#endregion
|
|
3887
4010
|
//#region lib/types/instance-scope.js
|
|
3888
4011
|
/**
|
|
3889
4012
|
* B3 / G4 (0.3.78): the family's single-instance contract, made explicit.
|
|
@@ -3901,11 +4024,30 @@ async function probeMtime(io, path) {
|
|
|
3901
4024
|
* the sidecar directory, so two instances resolving different homes (an
|
|
3902
4025
|
* isolated test fixture, a second DSH_HOME) do not contend, while two rows on
|
|
3903
4026
|
* one profile do.
|
|
4027
|
+
*
|
|
4028
|
+
* ## Scope (v43 FLOW2-1) — this registry is PER PROCESS
|
|
4029
|
+
*
|
|
4030
|
+
* `claims` below is a module-scope Map: two ROWS over one home in ONE process
|
|
4031
|
+
* contend, while the SAME home in another process gets its own Map and is
|
|
4032
|
+
* granted the key. That is by construction, not a gap to close here — the
|
|
4033
|
+
* cross-process half of the contract is the IO backend's write lock
|
|
4034
|
+
* (`transactIo`, core/io.ts), which serializes a per-target read-modify-write.
|
|
4035
|
+
* The FLOW2-1 finding was three call sites reading a GRANTED claim as "no other
|
|
4036
|
+
* process can be doing this work", so the caller contract is stated here:
|
|
4037
|
+
* - granted means "no other row OF THIS PROCESS owns the key";
|
|
4038
|
+
* - `instanceHolder()` answers "who holds it HERE"; `undefined` also covers
|
|
4039
|
+
* "held by another process";
|
|
4040
|
+
* - a foreign holder's LIVENESS cannot be decided from a claim at all (no pid
|
|
4041
|
+
* is recorded here): a consumer that needs that decision must carry a pid in
|
|
4042
|
+
* its own credential and probe it (`isProcessAlive`, core/io.ts), or state
|
|
4043
|
+
* that its action is destructive.
|
|
3904
4044
|
* @module @lmzhen/dsh-evolution-core/src/instance-scope
|
|
3905
4045
|
*/
|
|
3906
|
-
/** home+key -> holder.
|
|
3907
|
-
*
|
|
3908
|
-
* cross-process half is the write lock, see
|
|
4046
|
+
/** home+key -> holder, IN THIS PROCESS ONLY. The Map is module-scope, so two
|
|
4047
|
+
* processes never share it: this half cannot exclude another process (v43
|
|
4048
|
+
* FLOW2-1) — the cross-process half is the write lock, see
|
|
4049
|
+
* persisted-write-inventory.json. It is still the whole point for the case it
|
|
4050
|
+
* was written for: two ROWS of one process writing one home. */
|
|
3909
4051
|
const claims = /* @__PURE__ */ new Map();
|
|
3910
4052
|
/** `<home> :: <key>` — the registry key, exported so diagnostics name the
|
|
3911
4053
|
* same unit the claim does. */
|
|
@@ -3936,7 +4078,8 @@ function releaseInstance(home, key, owner) {
|
|
|
3936
4078
|
const id = instanceClaimKey(home, key);
|
|
3937
4079
|
if (claims.get(id) === owner) claims.delete(id);
|
|
3938
4080
|
}
|
|
3939
|
-
/** The current holder of `key` at `home`, or undefined
|
|
4081
|
+
/** The current holder of `key` at `home`, IN THIS PROCESS, or undefined
|
|
4082
|
+
* (which also covers "another process holds it" — v43 FLOW2-1). */
|
|
3940
4083
|
function instanceHolder(home, key) {
|
|
3941
4084
|
return claims.get(instanceClaimKey(home, key));
|
|
3942
4085
|
}
|
|
@@ -4001,16 +4144,45 @@ function parseSites(raw) {
|
|
|
4001
4144
|
const INSTANCE_KEYS = {
|
|
4002
4145
|
/** The per-home curator: report writing + the retention sweep. */
|
|
4003
4146
|
curator: "evolution-curator" };
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4147
|
+
let cachedSites;
|
|
4148
|
+
/**
|
|
4149
|
+
* The declared persisted write sites, in file order.
|
|
4150
|
+
*
|
|
4151
|
+
* v43 audit (S2-3 / P1-10): this used to be a module-scope readFileSync plus
|
|
4152
|
+
* parse, so an unshipped asset threw AT IMPORT — one absent file took the whole
|
|
4153
|
+
* family's load down (0.3.79 shipped a tarball without this asset and every
|
|
4154
|
+
* package failed to load). The read is lazy now: importing the package never
|
|
4155
|
+
* fails on this asset, while the first caller still gets a loud, descriptive
|
|
4156
|
+
* failure instead of an empty table ("no declared write sites" would silently
|
|
4157
|
+
* disable rule N20).
|
|
4158
|
+
* @internal S2.4 (PLAN 2026-09-16): no production caller — the arch guard
|
|
4159
|
+
* reads the JSON asset directly; the tests are the only in-tree consumers.
|
|
4160
|
+
* @returns the sites, in file order.
|
|
4161
|
+
*/
|
|
4162
|
+
function persistedWriteSites() {
|
|
4163
|
+
if (cachedSites !== void 0) return cachedSites;
|
|
4164
|
+
let raw;
|
|
4165
|
+
try {
|
|
4166
|
+
raw = readFileSync(fileURLToPath(SITES_URL), "utf8");
|
|
4167
|
+
} catch (error) {
|
|
4168
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
4169
|
+
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`);
|
|
4170
|
+
}
|
|
4171
|
+
cachedSites = parseSites(JSON.parse(raw));
|
|
4172
|
+
return cachedSites;
|
|
4173
|
+
}
|
|
4174
|
+
/** Sites serialized by the per-home instance claim, with their instance keys.
|
|
4175
|
+
* @internal S2.4 (PLAN 2026-09-16): no production caller — the arch guard
|
|
4176
|
+
* reads the JSON asset directly; the tests are the only in-tree consumers. */
|
|
4007
4177
|
function instanceClaimedWriteSites() {
|
|
4008
|
-
return
|
|
4178
|
+
return persistedWriteSites().filter((site) => site.serializedBy === "instance-claim");
|
|
4009
4179
|
}
|
|
4010
4180
|
/** One declared site by id. An undeclared id throws — a stale caller must fail
|
|
4011
|
-
* loud rather than read "nothing is declared".
|
|
4181
|
+
* loud rather than read "nothing is declared".
|
|
4182
|
+
* @internal S2.4 (PLAN 2026-09-16): no production caller — the arch guard
|
|
4183
|
+
* reads the JSON asset directly; the tests are the only in-tree consumers. */
|
|
4012
4184
|
function persistedWriteSite(id) {
|
|
4013
|
-
const site =
|
|
4185
|
+
const site = persistedWriteSites().find((candidate) => candidate.id === id);
|
|
4014
4186
|
if (site === void 0) throw new Error(`evolution-core: no persisted write site "${id}" in persisted-write-inventory.json`);
|
|
4015
4187
|
return site;
|
|
4016
4188
|
}
|
|
@@ -4020,11 +4192,6 @@ function callingScope(ctx, held) {
|
|
|
4020
4192
|
if (held !== void 0) return held;
|
|
4021
4193
|
return scopeOf(ctx);
|
|
4022
4194
|
}
|
|
4023
|
-
/** True when a read is deliberately scope-less (global layer only). Callers
|
|
4024
|
-
* pass this to the register so the choice is reviewed, not accidental. */
|
|
4025
|
-
function isGlobalRead(scope) {
|
|
4026
|
-
return scope === void 0;
|
|
4027
|
-
}
|
|
4028
4195
|
//#endregion
|
|
4029
4196
|
//#region lib/types/skill-health.js
|
|
4030
4197
|
/**
|
|
@@ -4238,21 +4405,34 @@ var ToolDispatchNormalizer = class {
|
|
|
4238
4405
|
this.maxTracked = options.maxTracked ?? Number.POSITIVE_INFINITY;
|
|
4239
4406
|
}
|
|
4240
4407
|
/**
|
|
4408
|
+
* v43 audit (FLOW4-4): the ledger key. A normalizer shared by every session
|
|
4409
|
+
* (skill-usage's live listener holds ONE process-wide instance) used the bare
|
|
4410
|
+
* call id, so two sessions that produced the same id — PTC sub-call ids are
|
|
4411
|
+
* short, and an id-less payload falls back to a type+payload key that is not
|
|
4412
|
+
* unique by construction — collided: the second session's read was absorbed as
|
|
4413
|
+
* "already seen" and never counted, and a settle in one session flipped the
|
|
4414
|
+
* other's `ok`. Callers that span sessions pass the session id as `scope`.
|
|
4415
|
+
*/
|
|
4416
|
+
keyOf(callId, scope) {
|
|
4417
|
+
return scope === "" ? callId : `${scope}:${callId}`;
|
|
4418
|
+
}
|
|
4419
|
+
/**
|
|
4241
4420
|
* Absorb one session event.
|
|
4242
4421
|
* @param event - the event to absorb; any non-dispatch event is ignored.
|
|
4243
4422
|
* @returns the dispatch's signal when this event FIRST reveals the dispatch,
|
|
4244
4423
|
* otherwise \`null\` (the paired event of an already-emitted dispatch, or a
|
|
4245
4424
|
* non-dispatch event). A \`null\` return is never a dispatch to count again.
|
|
4246
4425
|
*/
|
|
4247
|
-
advance(event) {
|
|
4426
|
+
advance(event, scope = "") {
|
|
4248
4427
|
const record = readDispatchRecord(event);
|
|
4249
4428
|
if (record === null) return null;
|
|
4429
|
+
const key = this.keyOf(record.callId, scope);
|
|
4250
4430
|
if (record.name === "") {
|
|
4251
|
-
const existing = this.records.get(
|
|
4431
|
+
const existing = this.records.get(key);
|
|
4252
4432
|
if (existing !== void 0 && record.outcome !== void 0) existing.ok = record.outcome.ok;
|
|
4253
4433
|
return null;
|
|
4254
4434
|
}
|
|
4255
|
-
const existing = this.records.get(
|
|
4435
|
+
const existing = this.records.get(key);
|
|
4256
4436
|
if (existing !== void 0) {
|
|
4257
4437
|
if (record.outcome !== void 0) existing.ok = record.outcome.ok;
|
|
4258
4438
|
return null;
|
|
@@ -4265,7 +4445,7 @@ var ToolDispatchNormalizer = class {
|
|
|
4265
4445
|
arguments: record.arguments,
|
|
4266
4446
|
ok: record.outcome?.ok
|
|
4267
4447
|
};
|
|
4268
|
-
this.records.set(
|
|
4448
|
+
this.records.set(key, signal);
|
|
4269
4449
|
this.evict();
|
|
4270
4450
|
return signal;
|
|
4271
4451
|
}
|
|
@@ -4279,13 +4459,14 @@ var ToolDispatchNormalizer = class {
|
|
|
4279
4459
|
* @param event - the event already absorbed by \`advance\`.
|
|
4280
4460
|
* @returns the dispatch this event settled, or \`null\`.
|
|
4281
4461
|
*/
|
|
4282
|
-
settledSignalOf(event) {
|
|
4462
|
+
settledSignalOf(event, scope = "") {
|
|
4283
4463
|
const record = readDispatchRecord(event);
|
|
4284
4464
|
if (record === null || record.outcome === void 0) return null;
|
|
4285
|
-
|
|
4286
|
-
|
|
4465
|
+
const key = this.keyOf(record.callId, scope);
|
|
4466
|
+
if (this.settledIds.has(key)) return null;
|
|
4467
|
+
const signal = this.records.get(key);
|
|
4287
4468
|
if (signal === void 0) return null;
|
|
4288
|
-
this.settledIds.add(
|
|
4469
|
+
this.settledIds.add(key);
|
|
4289
4470
|
this.evict();
|
|
4290
4471
|
return signal;
|
|
4291
4472
|
}
|
|
@@ -4692,7 +4873,8 @@ function computeDriftSignals(snapshots) {
|
|
|
4692
4873
|
const library = [];
|
|
4693
4874
|
const names = snapshots.map((s) => s.name);
|
|
4694
4875
|
const dedup = computeDedupGroups({ contents: new Map(snapshots.map((s) => [s.name, s.body])) });
|
|
4695
|
-
|
|
4876
|
+
const dedupTruncation = dedup.truncated ? " (dedup scan truncated at the pair-comparison budget)" : "";
|
|
4877
|
+
library.push(dedup.groups.length === 0 ? sig("dedup_group", "pass", `none${dedupTruncation}`, "size >= 2") : sig("dedup_group", "over", `${dedup.groups.map((group) => group.join(", ")).join(" | ")}${dedupTruncation}`, "size >= 2", `members=${dedup.groups.map((group) => group.join("|")).join(";")}`));
|
|
4696
4878
|
const clusters = computePrefixClusters(names);
|
|
4697
4879
|
library.push(clusters.length === 0 ? sig("prefix_cluster", "pass", "none", "size >= 2") : sig("prefix_cluster", "over", clusters.map((cluster) => cluster.members.join(", ")).join(" | "), "size >= 2", `key=${clusters.map((cluster) => cluster.key).join("|")}`));
|
|
4698
4880
|
const allProvided = snapshots.length > 0 && snapshots.every((s) => s.usageObserved !== null && s.usageObserved !== void 0);
|
|
@@ -4738,223 +4920,29 @@ function findDriftSignal(signals, id) {
|
|
|
4738
4920
|
return signals.find((signal) => signal.id === id);
|
|
4739
4921
|
}
|
|
4740
4922
|
//#endregion
|
|
4741
|
-
//#region lib/types/
|
|
4923
|
+
//#region lib/types/limits.js
|
|
4742
4924
|
/**
|
|
4743
|
-
* Skill
|
|
4744
|
-
*
|
|
4745
|
-
* Skills live under `$DSH_HOME/skills` (`~/.dsh/skills` by default), matching
|
|
4746
|
-
* the default dsh skill-filesystem user root. The plugin only manages skills
|
|
4747
|
-
* it created unless a `.hermes-managed` marker opts a skill in. Archival is a
|
|
4748
|
-
* move to `.archive/` — never a hard delete.
|
|
4749
|
-
*
|
|
4750
|
-
* ## Concurrency discipline (OPT-09, 2026-09) — read before adding a mutator
|
|
4751
|
-
*
|
|
4752
|
-
* Three primitives, three distinct jobs (they compose, they do not replace
|
|
4753
|
-
* each other):
|
|
4754
|
-
*
|
|
4755
|
-
* 1. **In-process serial queue** (`this.serial`, makeSerialQueue) — orders the
|
|
4756
|
-
* read→plan→commit phases of one skill's mutation against OTHER mutators
|
|
4757
|
-
* in this process. Used by: create/update/patch/setPinned/restructure/
|
|
4758
|
-
* writeSupportFile/removeSupportFile and (whole-mutation) consolidate.
|
|
4759
|
-
* NON-reentrant: a callback must never call a public method that wraps
|
|
4760
|
-
* itself in `this.serial` (archive/restoreFromArchive deliberately do not).
|
|
4761
|
-
* 2. **Per-directory write lock** (io.ts LOCK_*) — cross-process mutual
|
|
4762
|
-
* exclusion plus in-process crash ownership (tickets, takeover). Checked
|
|
4763
|
-
* with `hasWriteLock` before any destructive move (archive/restore/
|
|
4764
|
-
* snapshot); held inside transactIo by byte writers.
|
|
4765
|
-
* 3. **CAS baseline (`expected:`)** — any read whose bytes feed a later write
|
|
4766
|
-
* must either live inside the serial section that commits the write, or
|
|
4767
|
-
* carry its plan-time bytes as `expected` so the commit fails closed on
|
|
4768
|
-
* drift (V8-11 / V24-01). A read outside the serial section WITHOUT a
|
|
4769
|
-
* baseline is a lost-update bug; this file's history is the test suite.
|
|
4925
|
+
* Skill content limits: the byte/char budgets every write path validates against.
|
|
4770
4926
|
*
|
|
4771
|
-
*
|
|
4772
|
-
*
|
|
4773
|
-
*
|
|
4774
|
-
* (OPT-07); the movers' probe→rename window is owned by the io.ts protocol.
|
|
4927
|
+
* Split out of skill-store.ts (S2-1) so the frontmatter validators and the
|
|
4928
|
+
* store share one declaration site. Re-exported by skill-store.ts: the package
|
|
4929
|
+
* export surface is unchanged.
|
|
4775
4930
|
*/
|
|
4776
|
-
/** 0.3.16 (S1.13, T-6): the pointer-line prefix written into a body when a
|
|
4777
|
-
* section is moved to references/ — single literal, both restructure and
|
|
4778
|
-
* append-mode consolidation emit the same discoverability line. */
|
|
4779
|
-
const POINTER_LINE_PREFIX = "> 详见 references/";
|
|
4780
4931
|
const DEFAULT_SKILL_LIMITS = {
|
|
4781
4932
|
maxNameLength: 64,
|
|
4782
4933
|
maxDescriptionLength: MAX_DESCRIPTION_LENGTH,
|
|
4783
4934
|
maxSkillContentChars: MAX_SKILL_CONTENT_CHARS,
|
|
4784
4935
|
maxSkillFileBytes: MAX_SKILL_FILE_BYTES
|
|
4785
4936
|
};
|
|
4937
|
+
//#endregion
|
|
4938
|
+
//#region lib/types/frontmatter.js
|
|
4786
4939
|
/**
|
|
4787
|
-
*
|
|
4788
|
-
*
|
|
4789
|
-
*
|
|
4790
|
-
*
|
|
4940
|
+
* Frontmatter parsing, normalization and validation for skill Markdown files.
|
|
4941
|
+
*
|
|
4942
|
+
* Split out of skill-store.ts (S2-1): pure functions over file text, no store
|
|
4943
|
+
* state. skill-store.ts re-exports the same names it exported before the split,
|
|
4944
|
+
* so the package export surface is unchanged.
|
|
4791
4945
|
*/
|
|
4792
|
-
function anchorVerdict(anchor, current) {
|
|
4793
|
-
if (anchor === void 0) return "match";
|
|
4794
|
-
if ("absent" in anchor) return current === null ? "match" : "drift";
|
|
4795
|
-
if (current === null) return "missing";
|
|
4796
|
-
return contentHash(current) === anchor.sha256 ? "match" : "drift";
|
|
4797
|
-
}
|
|
4798
|
-
/**
|
|
4799
|
-
* Build the refusal for a skill write whose anchor did not hold. The wording is
|
|
4800
|
-
* the library's own; a caller with staged-replay wording (the skill tool, the
|
|
4801
|
-
* review plan) re-words it from {@link SkillActionResult.anchor}.
|
|
4802
|
-
* @param name - the skill name the refusal names.
|
|
4803
|
-
* @param verdict - the non-matching verdict.
|
|
4804
|
-
* @returns the refusal result (nothing was written).
|
|
4805
|
-
*/
|
|
4806
|
-
function anchorRefusal(name, verdict) {
|
|
4807
|
-
return {
|
|
4808
|
-
ok: false,
|
|
4809
|
-
stale: true,
|
|
4810
|
-
anchor: verdict,
|
|
4811
|
-
message: verdict === "missing" ? `Skill "${name}" not found.` : `Skill "${name}" changed since it was read; the write was refused to avoid overwriting newer content.`
|
|
4812
|
-
};
|
|
4813
|
-
}
|
|
4814
|
-
/**
|
|
4815
|
-
* Build the refusal for a support-file write/remove whose anchor did not hold.
|
|
4816
|
-
* @param name - the owning skill name.
|
|
4817
|
-
* @param filePath - the support-file path inside the skill.
|
|
4818
|
-
* @param verdict - the non-matching verdict.
|
|
4819
|
-
* @returns the refusal result (nothing was written or removed).
|
|
4820
|
-
*/
|
|
4821
|
-
/**
|
|
4822
|
-
* Refusal for a target the locked read could not verify at all (EISDIR, an
|
|
4823
|
-
* unreadable file). A staged replay reports "could not be verified" instead of
|
|
4824
|
-
* propagating an exception: nothing was read, so nothing can have been written.
|
|
4825
|
-
* @param name - the owning skill name.
|
|
4826
|
-
* @param filePath - the support-file path, or `null` for the skill body.
|
|
4827
|
-
* @returns the refusal result.
|
|
4828
|
-
*/
|
|
4829
|
-
function anchorUnverifiable(name, filePath) {
|
|
4830
|
-
return {
|
|
4831
|
-
ok: false,
|
|
4832
|
-
stale: true,
|
|
4833
|
-
anchor: "drift",
|
|
4834
|
-
message: filePath === null ? `Skill "${name}" could not be read to verify the staged content.` : `Support file "${filePath}" of "${name}" could not be read to verify the staged content.`
|
|
4835
|
-
};
|
|
4836
|
-
}
|
|
4837
|
-
function anchorRefusalFile(name, filePath, verdict) {
|
|
4838
|
-
return {
|
|
4839
|
-
ok: false,
|
|
4840
|
-
stale: true,
|
|
4841
|
-
anchor: verdict,
|
|
4842
|
-
message: verdict === "missing" ? `File "${filePath}" not found in skill "${name}".` : `Support file "${filePath}" of "${name}" changed since it was read; the write was refused to avoid overwriting newer content.`
|
|
4843
|
-
};
|
|
4844
|
-
}
|
|
4845
|
-
/** Upper bound of moves per restructure proposal (validator and core agree). */
|
|
4846
|
-
const MAX_RESTRUCTURE_MOVES = 5;
|
|
4847
|
-
/** Restructure targets are plain markdown files under references/ — no
|
|
4848
|
-
* subdirectories, no other support kind. V8-10 (0.3.47): the regex-level
|
|
4849
|
-
* `(?!.*\.\.)` keeps the restructure-created set EXACTLY the set
|
|
4850
|
-
* validateSupportPath can reopen — a `references/my..notes.md` target (double
|
|
4851
|
-
* dots) used to pass here while every later patch/write/remove on it was
|
|
4852
|
-
* refused as traversal (an orphan file the user could not touch). */
|
|
4853
|
-
const RESTRUCTURE_TARGET_RE = /^references\/[a-z0-9](?!.*\.\.)[a-z0-9._-]*\.md$/;
|
|
4854
|
-
/** F-20 (v18): the character rule shared by support-file names and snapshot
|
|
4855
|
-
* `extras/` entry names. The two exported names used to carry the same literal
|
|
4856
|
-
* independently; both now derive from this one. */
|
|
4857
|
-
const SUPPORT_ENTRY_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
4858
|
-
/** Extra file name carried inside a snapshot's `extras/` directory. */
|
|
4859
|
-
const SNAPSHOT_EXTRA_NAME_RE = SUPPORT_ENTRY_NAME_RE;
|
|
4860
|
-
function skillsRoot(env = process.env) {
|
|
4861
|
-
return join(evolutionRoot(env), "skills");
|
|
4862
|
-
}
|
|
4863
|
-
/** 0.3.18 (S4.1, E-30): the ONE root resolution for every member that reads
|
|
4864
|
-
* the skills tree — tool-skill-manage / evolution-skill-catalog / skill-usage
|
|
4865
|
-
* / evolution-learning-graph used to each resolve `config.root || skillsRoot()`
|
|
4866
|
-
* (and the graph ignored config entirely). Empty/whitespace config falls
|
|
4867
|
-
* through to the default; callers pass their raw Config. The optional field is
|
|
4868
|
-
* declared `| undefined` so a config object whose root field is explicitly
|
|
4869
|
-
* `string | undefined` still assignable under exactOptionalPropertyTypes. */
|
|
4870
|
-
function resolveSkillsRoot(config = {}) {
|
|
4871
|
-
return (config.root ?? "").trim() || skillsRoot();
|
|
4872
|
-
}
|
|
4873
|
-
/** E-7 (v18) → V27 G2.4 (M-08): every family row reads ONE root key. `root` is
|
|
4874
|
-
* canonical; the `skillsRoot` alias was honoured for one minor version and its
|
|
4875
|
-
* window closed at 0.3.65 — it is now two releases past expiry, so this
|
|
4876
|
-
* resolver no longer reads it at all. A deployment that still sets the alias
|
|
4877
|
-
* must fail LOUDLY at load (see {@link assertSkillsRootAliasRetired}): silently
|
|
4878
|
-
* ignoring a config key leaves the deployment pointing at a root nobody reads,
|
|
4879
|
-
* which is the worst form of compatibility.
|
|
4880
|
-
* @param config - the raw plugin config.
|
|
4881
|
-
* @returns the effective root (empty when the key is unset or blank).
|
|
4882
|
-
*/
|
|
4883
|
-
function resolveRootConfig(config = {}) {
|
|
4884
|
-
return { root: (config.root ?? "").trim() };
|
|
4885
|
-
}
|
|
4886
|
-
/** V27 G2.4 (M-08): the retirement gate for the expired `skillsRoot` alias.
|
|
4887
|
-
* Called at each plugin's load boundary (before the root is resolved), it turns
|
|
4888
|
-
* a stale key into an explicit load error naming the replacement — the
|
|
4889
|
-
* fail-loud form the plan requires instead of a silent no-op.
|
|
4890
|
-
* @param config - the raw plugin config (the alias field stays DECLARED in each
|
|
4891
|
-
* schema so the loader can hand it here instead of dropping it).
|
|
4892
|
-
*/
|
|
4893
|
-
function assertSkillsRootAliasRetired(config = {}) {
|
|
4894
|
-
if ((config.skillsRoot ?? "").trim() !== "") throw new Error("evolution: config \"skillsRoot\" was removed after 0.3.65 — rename the key to \"root\" (the alias is no longer honoured)");
|
|
4895
|
-
}
|
|
4896
|
-
/**
|
|
4897
|
-
* Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
|
|
4898
|
-
* the APPROVAL surface treats every delegated subagent as the autonomous
|
|
4899
|
-
* review channel, while the LIBRARY surface keeps the Hermes distinction -
|
|
4900
|
-
* the review fork is 'background_review' (the pinned guard blocks its
|
|
4901
|
-
* writes) and any other subagent is 'subagent' (agent-authored, not
|
|
4902
|
-
* review-channel). `isReview` marks the caller as the background review
|
|
4903
|
-
* pipeline itself. Single source: the two tools and the review executor all
|
|
4904
|
-
* read this table instead of re-deriving it.
|
|
4905
|
-
*/
|
|
4906
|
-
function resolveOrigins(headerOrigin, isReview = false) {
|
|
4907
|
-
if (isReview) return {
|
|
4908
|
-
approval: "background_review",
|
|
4909
|
-
library: "background_review"
|
|
4910
|
-
};
|
|
4911
|
-
if (headerOrigin === "subagent") return {
|
|
4912
|
-
approval: "background_review",
|
|
4913
|
-
library: "subagent"
|
|
4914
|
-
};
|
|
4915
|
-
return {
|
|
4916
|
-
approval: "foreground",
|
|
4917
|
-
library: "foreground"
|
|
4918
|
-
};
|
|
4919
|
-
}
|
|
4920
|
-
/**
|
|
4921
|
-
* S1-E8 (0.3.80): ONE exec→origins resolution for the two write tools — reads
|
|
4922
|
-
* the session header origin AND the v37 S2.2 review-channel session mark, so a
|
|
4923
|
-
* tool cannot forget the mark half (tool-memory shipped without it, which
|
|
4924
|
-
* mislabeled every inject-mode review memory write as `foreground` and let it
|
|
4925
|
-
* bypass staging under `stageForeground: false`).
|
|
4926
|
-
* Single source: both tools call this instead of re-deriving the pair.
|
|
4927
|
-
*/
|
|
4928
|
-
function resolveExecOrigins(exec) {
|
|
4929
|
-
const session = exec?.agent?.session;
|
|
4930
|
-
return resolveOrigins(session?.header?.origin, isReviewChannelSession(typeof session?.id === "string" ? session.id : void 0));
|
|
4931
|
-
}
|
|
4932
|
-
function skillDir(root, name) {
|
|
4933
|
-
return join(root, name);
|
|
4934
|
-
}
|
|
4935
|
-
/** Dot-prefixed on-disk marker name. SINGLE source: `list()` matches directory
|
|
4936
|
-
* entries against this name, and path builders must never hardcode a marker
|
|
4937
|
-
* literal (N-1: the rc.49 exists()-probe convergence dropped the dot,
|
|
4938
|
-
* poisoning every protectedBy/managed report). Exported for cross-package
|
|
4939
|
-
* consumers that must probe markers without re-deriving the name (curator's
|
|
4940
|
-
* archive-copy bundled probe, 0.3.26 V4-02). */
|
|
4941
|
-
function markerEntryName(marker) {
|
|
4942
|
-
return `.${marker}`;
|
|
4943
|
-
}
|
|
4944
|
-
/** F-17 (v18): the root-level lock files a DESTRUCTIVE MOVER must treat as an
|
|
4945
|
-
* active writer (skill body + the two marker writers). Single source with
|
|
4946
|
-
* `markerEntryName`/`LOCK_SUFFIX` so a renamed marker cannot silently drop out
|
|
4947
|
-
* of the ghost-writer probe. */
|
|
4948
|
-
const MARKER_LOCK_NAMES = [
|
|
4949
|
-
`SKILL.md${LOCK_SUFFIX}`,
|
|
4950
|
-
`.pinned${LOCK_SUFFIX}`,
|
|
4951
|
-
`.hermes-managed${LOCK_SUFFIX}`
|
|
4952
|
-
];
|
|
4953
|
-
/** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
|
|
4954
|
-
const ARCHIVE_RETENTION_DAYS = 365;
|
|
4955
|
-
function markerPath(dir, marker) {
|
|
4956
|
-
return join(dir, markerEntryName(marker));
|
|
4957
|
-
}
|
|
4958
4946
|
/**
|
|
4959
4947
|
* Shared frontmatter block detection (P3-3 single owner): opening line `---`
|
|
4960
4948
|
* and closing line exactly `---`. Used by `parseFrontmatter`,
|
|
@@ -5041,10 +5029,12 @@ const PLATFORM_STRING_FIELDS = [
|
|
|
5041
5029
|
"whenToUse"
|
|
5042
5030
|
];
|
|
5043
5031
|
/**
|
|
5044
|
-
* Frontmatter values as the STRICT platform catalog reads them —
|
|
5045
|
-
*
|
|
5046
|
-
*
|
|
5047
|
-
*
|
|
5032
|
+
* Frontmatter values as the STRICT platform catalog reads them — the `yaml`
|
|
5033
|
+
* package (YAML 1.2 core schema, the same dependency the platform's
|
|
5034
|
+
* skill-filesystem parses with — see the import note above),
|
|
5035
|
+
* the parser `normalizeFrontmatter` also verifies rewrites with — or `null`
|
|
5036
|
+
* when the block is not loadable as a YAML mapping. Also reports the platform
|
|
5037
|
+
* string fields whose value is not a string ({@link PlatformStringSplit}).
|
|
5048
5038
|
*
|
|
5049
5039
|
* Scalars publish their text (`name`, `description`, `whenToUse` are strings by
|
|
5050
5040
|
* contract; a number/boolean-shaped value keeps the text the family always
|
|
@@ -5062,7 +5052,7 @@ function strictFrontmatterValues(block) {
|
|
|
5062
5052
|
};
|
|
5063
5053
|
let loaded;
|
|
5064
5054
|
try {
|
|
5065
|
-
loaded =
|
|
5055
|
+
loaded = parse(block);
|
|
5066
5056
|
} catch {
|
|
5067
5057
|
return null;
|
|
5068
5058
|
}
|
|
@@ -5230,7 +5220,7 @@ function yamlPlainScalarNeedsQuotes(value) {
|
|
|
5230
5220
|
if (/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/.test(value)) return true;
|
|
5231
5221
|
if (/^0x[0-9a-f]+$/i.test(value)) return true;
|
|
5232
5222
|
if (/^0o[0-7]+$/.test(value)) return true;
|
|
5233
|
-
if (
|
|
5223
|
+
if (/^(?:[-+]?\.inf|\.nan)$/i.test(value)) return true;
|
|
5234
5224
|
if (/^[-?:,[\]{}#&*!|>'\"%@`\s]/.test(value)) return true;
|
|
5235
5225
|
return false;
|
|
5236
5226
|
}
|
|
@@ -5241,7 +5231,10 @@ function yamlPlainScalarNeedsQuotes(value) {
|
|
|
5241
5231
|
* unescaped inside single-quoted YAML). Idempotent; only single-line
|
|
5242
5232
|
* `key: value` entries are touched; body text is never modified; line-ending
|
|
5243
5233
|
* style is preserved. **Every rewrite is re-verified with the real YAML
|
|
5244
|
-
* parser** (
|
|
5234
|
+
* parser** (`yaml` — the package the platform's skill-filesystem catalog parses
|
|
5235
|
+
* with, YAML 1.2 core schema; PLAN S2.1, 2026-09-16 — the former "js-yaml, the
|
|
5236
|
+
* same parser" claim was false: js-yaml speaks YAML 1.1 full and diverged on
|
|
5237
|
+
* dates, timestamps and 1.1 int forms): if the
|
|
5245
5238
|
* rewritten block no longer parses, or a rewritten value's parsed content
|
|
5246
5239
|
* differs from the original, the rewrite is rolled back and reported in
|
|
5247
5240
|
* `issues` (fail-loud, never a silent value corruption — P3-4).
|
|
@@ -5304,7 +5297,7 @@ function normalizeFrontmatter(content) {
|
|
|
5304
5297
|
};
|
|
5305
5298
|
const rewrittenBlock = lines.slice(1, end).join("\n");
|
|
5306
5299
|
try {
|
|
5307
|
-
const parsed =
|
|
5300
|
+
const parsed = parse(rewrittenBlock);
|
|
5308
5301
|
for (const key of fields) if (String(parsed[key]) !== originalValues.get(key)) throw new Error(`rewritten value for ${key} differs from the original`);
|
|
5309
5302
|
return {
|
|
5310
5303
|
content: lines.join("\n"),
|
|
@@ -5341,65 +5334,391 @@ function relatedSkillNames(content, exclude) {
|
|
|
5341
5334
|
}
|
|
5342
5335
|
return [...names];
|
|
5343
5336
|
}
|
|
5344
|
-
/** S1.2 (v37 P2-1): the content limit applies to the bytes that LAND ON DISK.
|
|
5345
|
-
* Every write normalizes with `trimEnd() + '\n'`, so judging the raw argument let
|
|
5346
|
-
* a 100_000-character body with no trailing newline land as 100_001 bytes — and
|
|
5347
|
-
* every later patch/update of that skill was then refused, which made it
|
|
5348
|
-
* unmaintainable through `skill_manage` with no repair path at all. */
|
|
5349
|
-
function skillMdOnDisk(content) {
|
|
5350
|
-
return content.trimEnd() + "\n";
|
|
5337
|
+
/** S1.2 (v37 P2-1): the content limit applies to the bytes that LAND ON DISK.
|
|
5338
|
+
* Every write normalizes with `trimEnd() + '\n'`, so judging the raw argument let
|
|
5339
|
+
* a 100_000-character body with no trailing newline land as 100_001 bytes — and
|
|
5340
|
+
* every later patch/update of that skill was then refused, which made it
|
|
5341
|
+
* unmaintainable through `skill_manage` with no repair path at all. */
|
|
5342
|
+
function skillMdOnDisk(content) {
|
|
5343
|
+
return content.trimEnd() + "\n";
|
|
5344
|
+
}
|
|
5345
|
+
/** Whether `content` would exceed `limit` once written. */
|
|
5346
|
+
function exceedsContentLimit(content, limit) {
|
|
5347
|
+
return skillMdOnDisk(content).length > limit;
|
|
5348
|
+
}
|
|
5349
|
+
/** S1.2: the repair path — a write that makes an already-over-limit file smaller.
|
|
5350
|
+
* Only a NET SHRINK is exempt; an equal or larger write stays refused. */
|
|
5351
|
+
function shrinksOverLimit(next, current, limit) {
|
|
5352
|
+
if (current === null || current === void 0 || !exceedsContentLimit(current, limit)) return false;
|
|
5353
|
+
return skillMdOnDisk(next).length < skillMdOnDisk(current).length;
|
|
5354
|
+
}
|
|
5355
|
+
function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS, current) {
|
|
5356
|
+
const parsed = parseFrontmatter(content);
|
|
5357
|
+
if (!parsed) return "SKILL.md must start with a `---` line, close the frontmatter with another exact `---` line (only a trailing `\\r` is tolerated), and include a body below it.";
|
|
5358
|
+
const unreadable = parsed.platformStringSplit.filter((entry) => entry.kind === "sequence" || entry.kind === "mapping");
|
|
5359
|
+
if (unreadable.length > 0) return `Frontmatter field ${unreadable.map((entry) => `"${entry.key}" (a YAML ${entry.kind})`).join(", ")} must be a string: the platform skill catalog reads such a field as absent and ignores the whole file. Write plain text, or wrap the value in double quotes to keep it literally.`;
|
|
5360
|
+
if (!parsed.frontmatter.name) return "Frontmatter must include a name field.";
|
|
5361
|
+
if (!SKILL_NAME_RE.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
|
|
5362
|
+
if (parsed.frontmatter.name.length > limits.maxNameLength) return `Skill name exceeds ${limits.maxNameLength} characters.`;
|
|
5363
|
+
if (expectedName && parsed.frontmatter.name !== expectedName) return `Frontmatter name "${parsed.frontmatter.name}" does not match target skill "${expectedName}".`;
|
|
5364
|
+
if (!parsed.frontmatter.description) return "Frontmatter must include a description field.";
|
|
5365
|
+
if (parsed.frontmatter.description.length > limits.maxDescriptionLength) return `Description exceeds ${limits.maxDescriptionLength} characters.`;
|
|
5366
|
+
if (exceedsContentLimit(content, limits.maxSkillContentChars) && !shrinksOverLimit(content, current, limits.maxSkillContentChars)) return `SKILL.md content exceeds ${limits.maxSkillContentChars} characters. Consider splitting into a smaller SKILL.md with supporting files.`;
|
|
5367
|
+
return null;
|
|
5368
|
+
}
|
|
5369
|
+
/**
|
|
5370
|
+
* Advisory authoring feedback (P0): evaluate frontmatter against the
|
|
5371
|
+
* authoring bar WITHOUT changing platform validation semantics. The bar is
|
|
5372
|
+
* the quality target, `validateFrontmatter`'s limits are the compatibility
|
|
5373
|
+
* floor, and this bridge layer tells the model when its text would be
|
|
5374
|
+
* truncated or route-poor instead of silently shipping it.
|
|
5375
|
+
*/
|
|
5376
|
+
function authoringFeedback(frontmatter) {
|
|
5377
|
+
const description = frontmatter.description ?? "";
|
|
5378
|
+
const over60 = description.length > 60;
|
|
5379
|
+
const hasColon = description.includes(":");
|
|
5380
|
+
const lines = [];
|
|
5381
|
+
lines.push(over60 ? `Description is ${description.length}/60 characters — exceeds the 60-char authoring bar (Hermes standard; the catalog truncates at the configured platform cap).` : `Description ${description.length}/60 characters — within the authoring bar.`);
|
|
5382
|
+
if (hasColon) lines.push("Description contains a colon — wrap the whole value in double quotes.");
|
|
5383
|
+
return {
|
|
5384
|
+
descriptionChars: description.length,
|
|
5385
|
+
over60,
|
|
5386
|
+
hasColon,
|
|
5387
|
+
lines
|
|
5388
|
+
};
|
|
5389
|
+
}
|
|
5390
|
+
/** A1-15 (v18) / P2-2 (v19): the io layer marks an error `committed: true` when
|
|
5391
|
+
* the rename landed and only the directory fsync failed. Every single-file
|
|
5392
|
+
* writer must treat that as "written, durability unconfirmed" — never as a
|
|
5393
|
+
* plain failure (which a caller would retry, or a two-phase caller roll back).
|
|
5394
|
+
* v28 G2.1 (EVO-IO-05): this is a delegation to the seam's own
|
|
5395
|
+
* `isCommittedWarning` — the marker predicate has exactly one definition. */
|
|
5396
|
+
//#endregion
|
|
5397
|
+
//#region lib/types/fuzzy-match.js
|
|
5398
|
+
/**
|
|
5399
|
+
* Fuzzy string matching and replacement for patch/restructure edits of skill files.
|
|
5400
|
+
*
|
|
5401
|
+
* Split out of skill-store.ts (S2-1): pure text functions with no store state.
|
|
5402
|
+
* The store imports the scan, the budgets and the replace entry points directly;
|
|
5403
|
+
* none of them is re-exported, so the package export surface is unchanged.
|
|
5404
|
+
*/
|
|
5405
|
+
function fuzzyIndexOf(content, pattern, from = 0) {
|
|
5406
|
+
const isSpace = (char) => char !== void 0 && /[ \t]/.test(char);
|
|
5407
|
+
const escaped = (char) => {
|
|
5408
|
+
if (char === "n") return "\n";
|
|
5409
|
+
if (char === "t") return " ";
|
|
5410
|
+
if (char === "r") return "\r";
|
|
5411
|
+
return null;
|
|
5412
|
+
};
|
|
5413
|
+
for (let start = from; start < content.length; start += 1) {
|
|
5414
|
+
let contentIndex = start;
|
|
5415
|
+
let patternIndex = 0;
|
|
5416
|
+
while (patternIndex < pattern.length && contentIndex < content.length) {
|
|
5417
|
+
const patternChar = pattern[patternIndex];
|
|
5418
|
+
const contentChar = content[contentIndex];
|
|
5419
|
+
if (isSpace(patternChar)) {
|
|
5420
|
+
while (patternIndex < pattern.length && isSpace(pattern[patternIndex])) patternIndex += 1;
|
|
5421
|
+
while (contentIndex < content.length && isSpace(content[contentIndex])) contentIndex += 1;
|
|
5422
|
+
continue;
|
|
5423
|
+
}
|
|
5424
|
+
const escapedChar = patternChar === "\\" ? escaped(pattern[patternIndex + 1]) : null;
|
|
5425
|
+
if (escapedChar !== null && contentChar === escapedChar) {
|
|
5426
|
+
patternIndex += 2;
|
|
5427
|
+
contentIndex += 1;
|
|
5428
|
+
continue;
|
|
5429
|
+
}
|
|
5430
|
+
if (patternChar === contentChar) {
|
|
5431
|
+
contentIndex += 1;
|
|
5432
|
+
patternIndex += 1;
|
|
5433
|
+
continue;
|
|
5434
|
+
}
|
|
5435
|
+
break;
|
|
5436
|
+
}
|
|
5437
|
+
if (patternIndex === pattern.length) return [start, contentIndex];
|
|
5438
|
+
}
|
|
5439
|
+
return null;
|
|
5440
|
+
}
|
|
5441
|
+
/** Trim leading whitespace of the first line and trailing whitespace of the last line. */
|
|
5442
|
+
function trimPatternBoundaries(pattern) {
|
|
5443
|
+
const from = pattern.search(/\S/);
|
|
5444
|
+
const trimmed = from < 0 ? pattern : pattern.slice(from);
|
|
5445
|
+
const trailing = trimmed.search(/\s+$/);
|
|
5446
|
+
return trailing < 0 ? trimmed : trimmed.slice(0, trailing);
|
|
5447
|
+
}
|
|
5448
|
+
/** Replace only the fuzzy-matched span, preserving all surrounding bytes.
|
|
5449
|
+
* V7-11 (0.3.44): the replaceAll loop accumulates the per-scan cost — the
|
|
5450
|
+
* single-scan budget at the caller only bounded ONE fuzzyIndexOf, while an
|
|
5451
|
+
* unbounded number of matches × O(n·m) each could still stall the loop.
|
|
5452
|
+
* When the accumulated cost exceeds the budget the whole replace fails
|
|
5453
|
+
* (null) instead of partially applying an arbitrary prefix. */
|
|
5454
|
+
function fuzzyReplace(content, oldString, newString, replaceAll) {
|
|
5455
|
+
let current = content;
|
|
5456
|
+
let scanFrom = 0;
|
|
5457
|
+
let totalWork = 0;
|
|
5458
|
+
for (;;) {
|
|
5459
|
+
totalWork += current.length * oldString.length;
|
|
5460
|
+
if (totalWork > 8e6) return null;
|
|
5461
|
+
const match = fuzzyIndexOf(current, oldString, scanFrom);
|
|
5462
|
+
if (match === null) return current;
|
|
5463
|
+
const [start, end] = match;
|
|
5464
|
+
const next = current.slice(0, start) + newString + current.slice(end);
|
|
5465
|
+
if (!replaceAll) return next;
|
|
5466
|
+
current = next;
|
|
5467
|
+
scanFrom = start + newString.length;
|
|
5468
|
+
}
|
|
5469
|
+
}
|
|
5470
|
+
function fuzzyPatch(content, oldString, newString, replaceAll = false) {
|
|
5471
|
+
if (oldString === "") return null;
|
|
5472
|
+
if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, () => newString);
|
|
5473
|
+
const boundary = trimPatternBoundaries(oldString);
|
|
5474
|
+
if (boundary === "") return null;
|
|
5475
|
+
if (boundary !== oldString) {
|
|
5476
|
+
if (fuzzyIndexOf(content, boundary) !== null) return fuzzyReplace(content, boundary, newString, replaceAll);
|
|
5477
|
+
}
|
|
5478
|
+
if (fuzzyIndexOf(content, oldString) !== null) return fuzzyReplace(content, oldString, newString, replaceAll);
|
|
5479
|
+
return null;
|
|
5480
|
+
}
|
|
5481
|
+
/** Deterministic section-extraction plan facts; the caller owns the IO and the append semantics. */
|
|
5482
|
+
//#endregion
|
|
5483
|
+
//#region lib/types/skill-store.js
|
|
5484
|
+
/**
|
|
5485
|
+
* Skill library management for the self-evolution plugin.
|
|
5486
|
+
*
|
|
5487
|
+
* Skills live under `$DSH_HOME/skills` (`~/.dsh/skills` by default), matching
|
|
5488
|
+
* the default dsh skill-filesystem user root. The plugin only manages skills
|
|
5489
|
+
* it created unless a `.hermes-managed` marker opts a skill in. Archival is a
|
|
5490
|
+
* move to `.archive/` — never a hard delete.
|
|
5491
|
+
*
|
|
5492
|
+
* ## Concurrency discipline (OPT-09, 2026-09) — read before adding a mutator
|
|
5493
|
+
*
|
|
5494
|
+
* Three primitives, three distinct jobs (they compose, they do not replace
|
|
5495
|
+
* each other):
|
|
5496
|
+
*
|
|
5497
|
+
* 1. **In-process serial queue** (`this.serial`, makeSerialQueue) — orders the
|
|
5498
|
+
* read→plan→commit phases of one skill's mutation against OTHER mutators
|
|
5499
|
+
* in this process. Used by: create/update/patch/setPinned/restructure/
|
|
5500
|
+
* writeSupportFile/removeSupportFile and (whole-mutation) consolidate.
|
|
5501
|
+
* NON-reentrant: a callback must never call a public method that wraps
|
|
5502
|
+
* itself in `this.serial` (archive/restoreFromArchive deliberately do not).
|
|
5503
|
+
* 2. **Per-directory write lock** (io.ts LOCK_*) — cross-process mutual
|
|
5504
|
+
* exclusion plus in-process crash ownership (tickets, takeover). Checked
|
|
5505
|
+
* with `hasWriteLock` before any destructive move (archive/restore/
|
|
5506
|
+
* snapshot); held inside transactIo by byte writers.
|
|
5507
|
+
* 3. **CAS baseline (`expected:`)** — any read whose bytes feed a later write
|
|
5508
|
+
* must either live inside the serial section that commits the write, or
|
|
5509
|
+
* carry its plan-time bytes as `expected` so the commit fails closed on
|
|
5510
|
+
* drift (V8-11 / V24-01). A read outside the serial section WITHOUT a
|
|
5511
|
+
* baseline is a lost-update bug; this file's history is the test suite.
|
|
5512
|
+
*
|
|
5513
|
+
* Known residuals (deliberate, documented at their sites): the archive commit
|
|
5514
|
+
* re-check narrows but does not close the pin race (OPT-06); snapshotAll
|
|
5515
|
+
* re-probes after its copies so a mid-copy writer demotes to `skipped`
|
|
5516
|
+
* (OPT-07); the movers' probe→rename window is owned by the io.ts protocol.
|
|
5517
|
+
*/
|
|
5518
|
+
/** 0.3.16 (S1.13, T-6): the pointer-line prefix written into a body when a
|
|
5519
|
+
* section is moved to references/ — single literal, both restructure and
|
|
5520
|
+
* append-mode consolidation emit the same discoverability line. */
|
|
5521
|
+
const POINTER_LINE_PREFIX = "> 详见 references/";
|
|
5522
|
+
/**
|
|
5523
|
+
* Evaluate a stage-time anchor against the bytes a locked read observed.
|
|
5524
|
+
* @param anchor - the caller's anchor, or `undefined` for an unanchored write.
|
|
5525
|
+
* @param current - the bytes the write lock read (`null` = the target is absent).
|
|
5526
|
+
* @returns `match` when the write may proceed, otherwise the refusal verdict.
|
|
5527
|
+
*/
|
|
5528
|
+
function anchorVerdict(anchor, current) {
|
|
5529
|
+
if (anchor === void 0) return "match";
|
|
5530
|
+
if ("absent" in anchor) return current === null ? "match" : "drift";
|
|
5531
|
+
if (current === null) return "missing";
|
|
5532
|
+
return contentHash(current) === anchor.sha256 ? "match" : "drift";
|
|
5533
|
+
}
|
|
5534
|
+
/**
|
|
5535
|
+
* Build the refusal for a skill write whose anchor did not hold. The wording is
|
|
5536
|
+
* the library's own; a caller with staged-replay wording (the skill tool, the
|
|
5537
|
+
* review plan) re-words it from {@link SkillActionResult.anchor}.
|
|
5538
|
+
* @param name - the skill name the refusal names.
|
|
5539
|
+
* @param verdict - the non-matching verdict.
|
|
5540
|
+
* @returns the refusal result (nothing was written).
|
|
5541
|
+
*/
|
|
5542
|
+
function anchorRefusal(name, verdict) {
|
|
5543
|
+
return {
|
|
5544
|
+
ok: false,
|
|
5545
|
+
stale: true,
|
|
5546
|
+
anchor: verdict,
|
|
5547
|
+
message: verdict === "missing" ? `Skill "${name}" not found.` : `Skill "${name}" changed since it was read; the write was refused to avoid overwriting newer content.`
|
|
5548
|
+
};
|
|
5549
|
+
}
|
|
5550
|
+
/**
|
|
5551
|
+
* Build the refusal for a support-file write/remove whose anchor did not hold.
|
|
5552
|
+
* @param name - the owning skill name.
|
|
5553
|
+
* @param filePath - the support-file path inside the skill.
|
|
5554
|
+
* @param verdict - the non-matching verdict.
|
|
5555
|
+
* @returns the refusal result (nothing was written or removed).
|
|
5556
|
+
*/
|
|
5557
|
+
/**
|
|
5558
|
+
* Refusal for a target the locked read could not verify at all (EISDIR, an
|
|
5559
|
+
* unreadable file). A staged replay reports "could not be verified" instead of
|
|
5560
|
+
* propagating an exception: nothing was read, so nothing can have been written.
|
|
5561
|
+
* @param name - the owning skill name.
|
|
5562
|
+
* @param filePath - the support-file path, or `null` for the skill body.
|
|
5563
|
+
* @returns the refusal result.
|
|
5564
|
+
*/
|
|
5565
|
+
function anchorUnverifiable(name, filePath) {
|
|
5566
|
+
return {
|
|
5567
|
+
ok: false,
|
|
5568
|
+
stale: true,
|
|
5569
|
+
anchor: "drift",
|
|
5570
|
+
message: filePath === null ? `Skill "${name}" could not be read to verify the staged content.` : `Support file "${filePath}" of "${name}" could not be read to verify the staged content.`
|
|
5571
|
+
};
|
|
5572
|
+
}
|
|
5573
|
+
function anchorRefusalFile(name, filePath, verdict) {
|
|
5574
|
+
return {
|
|
5575
|
+
ok: false,
|
|
5576
|
+
stale: true,
|
|
5577
|
+
anchor: verdict,
|
|
5578
|
+
message: verdict === "missing" ? `File "${filePath}" not found in skill "${name}".` : `Support file "${filePath}" of "${name}" changed since it was read; the write was refused to avoid overwriting newer content.`
|
|
5579
|
+
};
|
|
5580
|
+
}
|
|
5581
|
+
/**
|
|
5582
|
+
* v43 S2-14 (FLOW2-1/flow-3): the target could not be READ at all — EISDIR, an
|
|
5583
|
+
* unreadable file, a failing backend. `io.readText` returns null only for a
|
|
5584
|
+
* genuinely missing path and throws for everything else, so a raw errno used to
|
|
5585
|
+
* escape `update` / `patch` / `write_file` to the model while the remove path had
|
|
5586
|
+
* classified the same condition since A-5 (v15). Structured refusal, nothing
|
|
5587
|
+
* written.
|
|
5588
|
+
*
|
|
5589
|
+
* @param label - `name` or `name/filePath`, as the caller's surface names it.
|
|
5590
|
+
* @param error - the read failure.
|
|
5591
|
+
* @returns the refusal result.
|
|
5592
|
+
*/
|
|
5593
|
+
function unreadableTarget(label, error) {
|
|
5594
|
+
return {
|
|
5595
|
+
ok: false,
|
|
5596
|
+
message: `Could not read "${label}": ${error?.code === "EISDIR" ? "the path is a DIRECTORY, not a file" : error instanceof Error ? error.message : String(error)}. Nothing was written.`
|
|
5597
|
+
};
|
|
5598
|
+
}
|
|
5599
|
+
/** Upper bound of moves per restructure proposal (validator and core agree). */
|
|
5600
|
+
const MAX_RESTRUCTURE_MOVES = 5;
|
|
5601
|
+
/** Restructure targets are plain markdown files under references/ — no
|
|
5602
|
+
* subdirectories, no other support kind. V8-10 (0.3.47): the regex-level
|
|
5603
|
+
* `(?!.*\.\.)` keeps the restructure-created set EXACTLY the set
|
|
5604
|
+
* validateSupportPath can reopen — a `references/my..notes.md` target (double
|
|
5605
|
+
* dots) used to pass here while every later patch/write/remove on it was
|
|
5606
|
+
* refused as traversal (an orphan file the user could not touch). */
|
|
5607
|
+
const RESTRUCTURE_TARGET_RE = /^references\/[a-z0-9](?!.*\.\.)[a-z0-9._-]*\.md$/;
|
|
5608
|
+
/** F-20 (v18): the character rule shared by support-file names and snapshot
|
|
5609
|
+
* `extras/` entry names. The two exported names used to carry the same literal
|
|
5610
|
+
* independently; both now derive from this one. */
|
|
5611
|
+
const SUPPORT_ENTRY_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
5612
|
+
/** Extra file name carried inside a snapshot's `extras/` directory. */
|
|
5613
|
+
const SNAPSHOT_EXTRA_NAME_RE = SUPPORT_ENTRY_NAME_RE;
|
|
5614
|
+
function skillsRoot(env = process.env) {
|
|
5615
|
+
return join(evolutionRoot(env), "skills");
|
|
5351
5616
|
}
|
|
5352
|
-
/**
|
|
5353
|
-
|
|
5354
|
-
|
|
5617
|
+
/** 0.3.18 (S4.1, E-30): the ONE root resolution for every member that reads
|
|
5618
|
+
* the skills tree — tool-skill-manage / evolution-skill-catalog / skill-usage
|
|
5619
|
+
* / evolution-learning-graph used to each resolve `config.root || skillsRoot()`
|
|
5620
|
+
* (and the graph ignored config entirely). Empty/whitespace config falls
|
|
5621
|
+
* through to the default; callers pass their raw Config. The optional field is
|
|
5622
|
+
* declared `| undefined` so a config object whose root field is explicitly
|
|
5623
|
+
* `string | undefined` still assignable under exactOptionalPropertyTypes.
|
|
5624
|
+
* P2-31 core half (S2.2 batch, PLAN 2026-09-16): an explicit non-empty root
|
|
5625
|
+
* is `resolve()`d — the same normalization `evolutionRoot` applies and the
|
|
5626
|
+
* same one upstream skill-filesystem applies to `customSkillDirs`
|
|
5627
|
+
* (`(config.customSkillDirs ?? []).map(root => resolve(root))`). The former
|
|
5628
|
+
* verbatim return left a RELATIVE config root CWD-relative, so the family's
|
|
5629
|
+
* skill tree moved with the host process's launch directory while every
|
|
5630
|
+
* absolute consumer (watchers, the platform catalog) resolved it — a
|
|
5631
|
+
* split-brain tree; a trailing slash or `..` segment likewise landed
|
|
5632
|
+
* unnormalized. A clean absolute root is byte-identical (resolve is a no-op). */
|
|
5633
|
+
function resolveSkillsRoot(config = {}) {
|
|
5634
|
+
const explicit = (config.root ?? "").trim();
|
|
5635
|
+
return explicit ? resolve(explicit) : skillsRoot();
|
|
5355
5636
|
}
|
|
5356
|
-
/**
|
|
5357
|
-
*
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5637
|
+
/** E-7 (v18) → V27 G2.4 (M-08): every family row reads ONE root key. `root` is
|
|
5638
|
+
* canonical; the `skillsRoot` alias was honoured for one minor version and its
|
|
5639
|
+
* window closed at 0.3.65 — it is now two releases past expiry, so this
|
|
5640
|
+
* resolver no longer reads it at all. A deployment that still sets the alias
|
|
5641
|
+
* must fail LOUDLY at load (see {@link assertSkillsRootAliasRetired}): silently
|
|
5642
|
+
* ignoring a config key leaves the deployment pointing at a root nobody reads,
|
|
5643
|
+
* which is the worst form of compatibility.
|
|
5644
|
+
* @param config - the raw plugin config.
|
|
5645
|
+
* @returns the effective root (empty when the key is unset or blank).
|
|
5646
|
+
*/
|
|
5647
|
+
function resolveRootConfig(config = {}) {
|
|
5648
|
+
return { root: (config.root ?? "").trim() };
|
|
5361
5649
|
}
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
if (
|
|
5371
|
-
if (!parsed.frontmatter.description) return "Frontmatter must include a description field.";
|
|
5372
|
-
if (parsed.frontmatter.description.length > limits.maxDescriptionLength) return `Description exceeds ${limits.maxDescriptionLength} characters.`;
|
|
5373
|
-
if (exceedsContentLimit(content, limits.maxSkillContentChars) && !shrinksOverLimit(content, current, limits.maxSkillContentChars)) return `SKILL.md content exceeds ${limits.maxSkillContentChars} characters. Consider splitting into a smaller SKILL.md with supporting files.`;
|
|
5374
|
-
return null;
|
|
5650
|
+
/** V27 G2.4 (M-08): the retirement gate for the expired `skillsRoot` alias.
|
|
5651
|
+
* Called at each plugin's load boundary (before the root is resolved), it turns
|
|
5652
|
+
* a stale key into an explicit load error naming the replacement — the
|
|
5653
|
+
* fail-loud form the plan requires instead of a silent no-op.
|
|
5654
|
+
* @param config - the raw plugin config (the alias field stays DECLARED in each
|
|
5655
|
+
* schema so the loader can hand it here instead of dropping it).
|
|
5656
|
+
*/
|
|
5657
|
+
function assertSkillsRootAliasRetired(config = {}) {
|
|
5658
|
+
if ((config.skillsRoot ?? "").trim() !== "") throw new Error("evolution: config \"skillsRoot\" was removed after 0.3.65 — rename the key to \"root\" (the alias is no longer honoured)");
|
|
5375
5659
|
}
|
|
5376
5660
|
/**
|
|
5377
|
-
*
|
|
5378
|
-
*
|
|
5379
|
-
*
|
|
5380
|
-
*
|
|
5381
|
-
*
|
|
5661
|
+
* Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
|
|
5662
|
+
* the APPROVAL surface treats every delegated subagent as the autonomous
|
|
5663
|
+
* review channel, while the LIBRARY surface keeps the Hermes distinction -
|
|
5664
|
+
* the review fork is 'background_review' (the pinned guard blocks its
|
|
5665
|
+
* writes) and any other subagent is 'subagent' (agent-authored, not
|
|
5666
|
+
* review-channel). `isReview` marks the caller as the background review
|
|
5667
|
+
* pipeline itself. Single source: the two tools and the review executor all
|
|
5668
|
+
* read this table instead of re-deriving it.
|
|
5382
5669
|
*/
|
|
5383
|
-
function
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5670
|
+
function resolveOrigins(headerOrigin, isReview = false) {
|
|
5671
|
+
if (isReview) return {
|
|
5672
|
+
approval: "background_review",
|
|
5673
|
+
library: "background_review"
|
|
5674
|
+
};
|
|
5675
|
+
if (headerOrigin === "subagent") return {
|
|
5676
|
+
approval: "background_review",
|
|
5677
|
+
library: "subagent"
|
|
5678
|
+
};
|
|
5390
5679
|
return {
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
hasColon,
|
|
5394
|
-
lines
|
|
5680
|
+
approval: "foreground",
|
|
5681
|
+
library: "foreground"
|
|
5395
5682
|
};
|
|
5396
5683
|
}
|
|
5397
|
-
/**
|
|
5398
|
-
*
|
|
5399
|
-
*
|
|
5400
|
-
*
|
|
5401
|
-
*
|
|
5402
|
-
*
|
|
5684
|
+
/**
|
|
5685
|
+
* S1-E8 (0.3.80): ONE exec→origins resolution for the two write tools — reads
|
|
5686
|
+
* the session header origin AND the v37 S2.2 review-channel session mark, so a
|
|
5687
|
+
* tool cannot forget the mark half (tool-memory shipped without it, which
|
|
5688
|
+
* mislabeled every inject-mode review memory write as `foreground` and let it
|
|
5689
|
+
* bypass staging under `stageForeground: false`).
|
|
5690
|
+
* Single source: both tools call this instead of re-deriving the pair.
|
|
5691
|
+
*/
|
|
5692
|
+
function resolveExecOrigins(exec) {
|
|
5693
|
+
const session = exec?.agent?.session;
|
|
5694
|
+
return resolveOrigins(session?.header?.origin, isReviewChannelSession(typeof session?.id === "string" ? session.id : void 0));
|
|
5695
|
+
}
|
|
5696
|
+
function skillDir(root, name) {
|
|
5697
|
+
return join(root, name);
|
|
5698
|
+
}
|
|
5699
|
+
/** Dot-prefixed on-disk marker name. SINGLE source: `list()` matches directory
|
|
5700
|
+
* entries against this name, and path builders must never hardcode a marker
|
|
5701
|
+
* literal (N-1: the rc.49 exists()-probe convergence dropped the dot,
|
|
5702
|
+
* poisoning every protectedBy/managed report). Exported for cross-package
|
|
5703
|
+
* consumers that must probe markers without re-deriving the name (curator's
|
|
5704
|
+
* archive-copy bundled probe, 0.3.26 V4-02). */
|
|
5705
|
+
function markerEntryName(marker) {
|
|
5706
|
+
return `.${marker}`;
|
|
5707
|
+
}
|
|
5708
|
+
/** F-17 (v18): the root-level lock files a DESTRUCTIVE MOVER must treat as an
|
|
5709
|
+
* active writer (skill body + the two marker writers). Single source with
|
|
5710
|
+
* `markerEntryName`/`LOCK_SUFFIX` so a renamed marker cannot silently drop out
|
|
5711
|
+
* of the ghost-writer probe. */
|
|
5712
|
+
const MARKER_LOCK_NAMES = [
|
|
5713
|
+
`SKILL.md${LOCK_SUFFIX}`,
|
|
5714
|
+
`.pinned${LOCK_SUFFIX}`,
|
|
5715
|
+
`.hermes-managed${LOCK_SUFFIX}`
|
|
5716
|
+
];
|
|
5717
|
+
/** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
|
|
5718
|
+
const ARCHIVE_RETENTION_DAYS = 365;
|
|
5719
|
+
function markerPath(dir, marker) {
|
|
5720
|
+
return join(dir, markerEntryName(marker));
|
|
5721
|
+
}
|
|
5403
5722
|
function isCommittedOnly(error) {
|
|
5404
5723
|
return isCommittedWarning(error);
|
|
5405
5724
|
}
|
|
@@ -5481,99 +5800,6 @@ function validateSupportPath(filePath) {
|
|
|
5481
5800
|
return null;
|
|
5482
5801
|
}
|
|
5483
5802
|
/**
|
|
5484
|
-
* Index of `pattern` inside `content`, treating whitespace runs (spaces/tabs)
|
|
5485
|
-
* as flexible and literal escape sequences (`\n`, `\t`, `\r`) as their real
|
|
5486
|
-
* characters: a PATTERN whitespace run matches any content run of any length
|
|
5487
|
-
* (even empty), while extra whitespace that only exists in the content is not
|
|
5488
|
-
* skipped — the flexibility is one-sided on the pattern, and a backslash-
|
|
5489
|
-
* escaped char in the pattern matches the real char in the content
|
|
5490
|
-
* (model-copy drift). Returns the [start, end) range in the ORIGINAL content
|
|
5491
|
-
* so a patch can replace exactly the matched span and keep every other byte
|
|
5492
|
-
* intact. Returns null when no fuzzy match exists.
|
|
5493
|
-
*/
|
|
5494
|
-
function fuzzyIndexOf(content, pattern, from = 0) {
|
|
5495
|
-
const isSpace = (char) => char !== void 0 && /[ \t]/.test(char);
|
|
5496
|
-
const escaped = (char) => {
|
|
5497
|
-
if (char === "n") return "\n";
|
|
5498
|
-
if (char === "t") return " ";
|
|
5499
|
-
if (char === "r") return "\r";
|
|
5500
|
-
return null;
|
|
5501
|
-
};
|
|
5502
|
-
for (let start = from; start < content.length; start += 1) {
|
|
5503
|
-
let contentIndex = start;
|
|
5504
|
-
let patternIndex = 0;
|
|
5505
|
-
while (patternIndex < pattern.length && contentIndex < content.length) {
|
|
5506
|
-
const patternChar = pattern[patternIndex];
|
|
5507
|
-
const contentChar = content[contentIndex];
|
|
5508
|
-
if (isSpace(patternChar)) {
|
|
5509
|
-
while (patternIndex < pattern.length && isSpace(pattern[patternIndex])) patternIndex += 1;
|
|
5510
|
-
while (contentIndex < content.length && isSpace(content[contentIndex])) contentIndex += 1;
|
|
5511
|
-
continue;
|
|
5512
|
-
}
|
|
5513
|
-
const escapedChar = patternChar === "\\" ? escaped(pattern[patternIndex + 1]) : null;
|
|
5514
|
-
if (escapedChar !== null && contentChar === escapedChar) {
|
|
5515
|
-
patternIndex += 2;
|
|
5516
|
-
contentIndex += 1;
|
|
5517
|
-
continue;
|
|
5518
|
-
}
|
|
5519
|
-
if (patternChar === contentChar) {
|
|
5520
|
-
contentIndex += 1;
|
|
5521
|
-
patternIndex += 1;
|
|
5522
|
-
continue;
|
|
5523
|
-
}
|
|
5524
|
-
break;
|
|
5525
|
-
}
|
|
5526
|
-
if (patternIndex === pattern.length) return [start, contentIndex];
|
|
5527
|
-
}
|
|
5528
|
-
return null;
|
|
5529
|
-
}
|
|
5530
|
-
/** V6-17 (0.3.37): the fuzzy-patch scan is O(n·m) with no input bound; a
|
|
5531
|
-
* non-exact anchor past these budgets would block the event loop (measured
|
|
5532
|
-
* ~6s at 20k×20k). Exact matches go through the fast `includes` path and stay
|
|
5533
|
-
* allowed regardless of size. */
|
|
5534
|
-
const FUZZY_MAX_PATTERN_CHARS = 4096;
|
|
5535
|
-
const FUZZY_MAX_WORK = 8e6;
|
|
5536
|
-
/** Trim leading whitespace of the first line and trailing whitespace of the last line. */
|
|
5537
|
-
function trimPatternBoundaries(pattern) {
|
|
5538
|
-
const from = pattern.search(/\S/);
|
|
5539
|
-
const trimmed = from < 0 ? pattern : pattern.slice(from);
|
|
5540
|
-
const trailing = trimmed.search(/\s+$/);
|
|
5541
|
-
return trailing < 0 ? trimmed : trimmed.slice(0, trailing);
|
|
5542
|
-
}
|
|
5543
|
-
/** Replace only the fuzzy-matched span, preserving all surrounding bytes.
|
|
5544
|
-
* V7-11 (0.3.44): the replaceAll loop accumulates the per-scan cost — the
|
|
5545
|
-
* single-scan budget at the caller only bounded ONE fuzzyIndexOf, while an
|
|
5546
|
-
* unbounded number of matches × O(n·m) each could still stall the loop.
|
|
5547
|
-
* When the accumulated cost exceeds the budget the whole replace fails
|
|
5548
|
-
* (null) instead of partially applying an arbitrary prefix. */
|
|
5549
|
-
function fuzzyReplace(content, oldString, newString, replaceAll) {
|
|
5550
|
-
let current = content;
|
|
5551
|
-
let scanFrom = 0;
|
|
5552
|
-
let totalWork = 0;
|
|
5553
|
-
for (;;) {
|
|
5554
|
-
totalWork += current.length * oldString.length;
|
|
5555
|
-
if (totalWork > FUZZY_MAX_WORK) return null;
|
|
5556
|
-
const match = fuzzyIndexOf(current, oldString, scanFrom);
|
|
5557
|
-
if (match === null) return current;
|
|
5558
|
-
const [start, end] = match;
|
|
5559
|
-
const next = current.slice(0, start) + newString + current.slice(end);
|
|
5560
|
-
if (!replaceAll) return next;
|
|
5561
|
-
current = next;
|
|
5562
|
-
scanFrom = start + newString.length;
|
|
5563
|
-
}
|
|
5564
|
-
}
|
|
5565
|
-
function fuzzyPatch(content, oldString, newString, replaceAll = false) {
|
|
5566
|
-
if (oldString === "") return null;
|
|
5567
|
-
if (content.includes(oldString)) return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, () => newString);
|
|
5568
|
-
const boundary = trimPatternBoundaries(oldString);
|
|
5569
|
-
if (boundary === "") return null;
|
|
5570
|
-
if (boundary !== oldString) {
|
|
5571
|
-
if (fuzzyIndexOf(content, boundary) !== null) return fuzzyReplace(content, boundary, newString, replaceAll);
|
|
5572
|
-
}
|
|
5573
|
-
if (fuzzyIndexOf(content, oldString) !== null) return fuzzyReplace(content, oldString, newString, replaceAll);
|
|
5574
|
-
return null;
|
|
5575
|
-
}
|
|
5576
|
-
/**
|
|
5577
5803
|
* Support-directory references in a markdown body (009 kernel): `references/…`,
|
|
5578
5804
|
* `templates/…`, `scripts/…`, `assets/…` relative links — any extension and
|
|
5579
5805
|
* nested paths (v7 audit P3-1: `.md`-only matching missed `scripts/run.sh` and
|
|
@@ -5692,7 +5918,7 @@ var SkillLibrary = class {
|
|
|
5692
5918
|
if (this.transact) try {
|
|
5693
5919
|
await this.transact(this.io, path, run);
|
|
5694
5920
|
} catch (error) {
|
|
5695
|
-
if (!progress.entered && readFailure !== void 0) return readFailure;
|
|
5921
|
+
if (!progress.entered && readFailure !== void 0) return readFailure(error);
|
|
5696
5922
|
if (!committedOnly(error)) throw error;
|
|
5697
5923
|
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
5698
5924
|
}
|
|
@@ -5701,7 +5927,7 @@ var SkillLibrary = class {
|
|
|
5701
5927
|
try {
|
|
5702
5928
|
current = await this.io.readText(path);
|
|
5703
5929
|
} catch (error) {
|
|
5704
|
-
if (readFailure !== void 0) return readFailure;
|
|
5930
|
+
if (readFailure !== void 0) return readFailure(error);
|
|
5705
5931
|
throw error;
|
|
5706
5932
|
}
|
|
5707
5933
|
const next = await run(current);
|
|
@@ -6104,7 +6330,17 @@ var SkillLibrary = class {
|
|
|
6104
6330
|
ok: false,
|
|
6105
6331
|
message: `Skill "${normalized}" already exists.`
|
|
6106
6332
|
};
|
|
6107
|
-
|
|
6333
|
+
let rootEntries;
|
|
6334
|
+
try {
|
|
6335
|
+
rootEntries = await this.io.list(this.root);
|
|
6336
|
+
} catch (error) {
|
|
6337
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
6338
|
+
return {
|
|
6339
|
+
ok: false,
|
|
6340
|
+
message: `Skill "${normalized}" could not be checked for a case-variant collision: listing ${this.root} failed (${cause}). Refusing the create — a second directory differing only in case cannot be undone across platforms.`
|
|
6341
|
+
};
|
|
6342
|
+
}
|
|
6343
|
+
for (const entry of rootEntries) if (typeof entry === "string" && entry !== normalized && entry.toLowerCase() === normalized.toLowerCase()) return {
|
|
6108
6344
|
ok: false,
|
|
6109
6345
|
message: `Skill "${normalized}" collides with the existing case-variant directory "${entry}" (skill names are lowercase-only); rename one of them.`
|
|
6110
6346
|
};
|
|
@@ -6120,6 +6356,7 @@ var SkillLibrary = class {
|
|
|
6120
6356
|
let createDurabilityWarning = "";
|
|
6121
6357
|
if (this.transact) try {
|
|
6122
6358
|
await this.transact(this.io, createPath, (current) => {
|
|
6359
|
+
existsAtCommit = false;
|
|
6123
6360
|
taskRan = true;
|
|
6124
6361
|
if (current !== null) {
|
|
6125
6362
|
existsAtCommit = true;
|
|
@@ -6267,7 +6504,7 @@ var SkillLibrary = class {
|
|
|
6267
6504
|
skillDir: dir
|
|
6268
6505
|
}
|
|
6269
6506
|
};
|
|
6270
|
-
}, anchor !== void 0 ? anchorUnverifiable(name, null) :
|
|
6507
|
+
}, anchor !== void 0 ? () => anchorUnverifiable(name, null) : (error) => unreadableTarget(name, error));
|
|
6271
6508
|
}
|
|
6272
6509
|
async patch(rawName, oldString, newString, filePath = "", replaceAll = false, origin = "foreground") {
|
|
6273
6510
|
const name = rawName.trim();
|
|
@@ -6310,7 +6547,7 @@ var SkillLibrary = class {
|
|
|
6310
6547
|
},
|
|
6311
6548
|
write: null
|
|
6312
6549
|
};
|
|
6313
|
-
if (!md.includes(oldString) && (oldString.length >
|
|
6550
|
+
if (!md.includes(oldString) && (oldString.length > 4096 || md.length * oldString.length > 8e6)) return {
|
|
6314
6551
|
result: {
|
|
6315
6552
|
ok: false,
|
|
6316
6553
|
message: `old_string too large for fuzzy match (${oldString.length} chars in ${patchLabel}); use update for a full rewrite or a narrower anchor.`
|
|
@@ -6421,7 +6658,7 @@ var SkillLibrary = class {
|
|
|
6421
6658
|
skillDir: dir
|
|
6422
6659
|
}
|
|
6423
6660
|
};
|
|
6424
|
-
});
|
|
6661
|
+
}, (error) => unreadableTarget(name + "/" + patchLabel, error));
|
|
6425
6662
|
}
|
|
6426
6663
|
/**
|
|
6427
6664
|
* P2-9 (v15): the destructive directory move shared by archive and
|
|
@@ -7009,6 +7246,7 @@ var SkillLibrary = class {
|
|
|
7009
7246
|
const drift = { seen: false };
|
|
7010
7247
|
const ran = { done: false };
|
|
7011
7248
|
await this.transact(this.io, entry.target, (current) => {
|
|
7249
|
+
drift.seen = false;
|
|
7012
7250
|
ran.done = true;
|
|
7013
7251
|
if (current !== baseline) {
|
|
7014
7252
|
drift.seen = true;
|
|
@@ -7223,7 +7461,7 @@ var SkillLibrary = class {
|
|
|
7223
7461
|
file: target
|
|
7224
7462
|
}
|
|
7225
7463
|
};
|
|
7226
|
-
}, anchor !== void 0 ? anchorUnverifiable(name, filePath) :
|
|
7464
|
+
}, anchor !== void 0 ? () => anchorUnverifiable(name, filePath) : (error) => unreadableTarget(name + "/" + filePath, error));
|
|
7227
7465
|
}
|
|
7228
7466
|
async removeSupportFile(rawName, filePath, origin = "foreground", anchor) {
|
|
7229
7467
|
const name = rawName.trim();
|
|
@@ -7651,6 +7889,35 @@ function newSkillLibrary(options) {
|
|
|
7651
7889
|
*/
|
|
7652
7890
|
const FAMILY_SESSION_TOOL_NAMES = ["skill_manage", "memory"];
|
|
7653
7891
|
/**
|
|
7892
|
+
* S0-4 (v43 J-1 / G-1): the process-wide witness behind the deployment
|
|
7893
|
+
* diagnostic. A scoped false is the CORRECT answer for a session that did not
|
|
7894
|
+
* opt in, so the miss alone proves nothing — "no session in this process ever
|
|
7895
|
+
* matched" is the shape both real faults share, and only a witness that outlives
|
|
7896
|
+
* one session can tell it from a healthy per-session skip.
|
|
7897
|
+
*
|
|
7898
|
+
* Module scope on purpose: the claim spans every session and every row of this
|
|
7899
|
+
* process, so no single row's fiber owns it. Monotone scalars with no per-key
|
|
7900
|
+
* lifecycle — the N12 registry covers module-scope Set/Map/WeakMap stores, which
|
|
7901
|
+
* carry entries that do need one.
|
|
7902
|
+
*/
|
|
7903
|
+
let scopedProbeHits = 0;
|
|
7904
|
+
let scopedProbeMisses = 0;
|
|
7905
|
+
let scopedProbeWarned = false;
|
|
7906
|
+
/**
|
|
7907
|
+
* Read the witness for a diagnostic surface (`/evolution doctor`). Read-only: it
|
|
7908
|
+
* neither evaluates the probe nor consumes the one-time warn, so a report run
|
|
7909
|
+
* cannot change what the next miss would have logged.
|
|
7910
|
+
* @returns the verdict with both counts, zeroed in a process where the gate has
|
|
7911
|
+
* not run.
|
|
7912
|
+
*/
|
|
7913
|
+
function scopedProbeReport() {
|
|
7914
|
+
return {
|
|
7915
|
+
verdict: scopedProbeHits > 0 ? "hit" : scopedProbeMisses > 0 ? "never-hit" : "idle",
|
|
7916
|
+
hits: scopedProbeHits,
|
|
7917
|
+
misses: scopedProbeMisses
|
|
7918
|
+
};
|
|
7919
|
+
}
|
|
7920
|
+
/**
|
|
7654
7921
|
* Does this session's scope see the family's model tools?
|
|
7655
7922
|
*
|
|
7656
7923
|
* The scope is the live agent's — the platform's own addressing for "what does
|
|
@@ -7671,6 +7938,23 @@ function sessionSeesFamilyTools(ctx, sessionId) {
|
|
|
7671
7938
|
return FAMILY_SESSION_TOOL_NAMES.some((name) => tools.get(name, scope) !== void 0);
|
|
7672
7939
|
}
|
|
7673
7940
|
/**
|
|
7941
|
+
* S0-4 (v43 J-1 / G-1): record a scoped rejection and leave the one-time
|
|
7942
|
+
* deployment diagnostic.
|
|
7943
|
+
*
|
|
7944
|
+
* Fires at most once per process, on the first miss, and only while nothing has
|
|
7945
|
+
* ever matched. The message names the check rather than guessing the deployment
|
|
7946
|
+
* form: a host-only install and a disabled model row are the two shapes that can
|
|
7947
|
+
* never match, while the layered variant form legitimately waits for a session
|
|
7948
|
+
* that selected the Evolution preset.
|
|
7949
|
+
* @param ctx - the asking row's context; the warn rides that row's own logger.
|
|
7950
|
+
*/
|
|
7951
|
+
function noteScopedProbeMiss(ctx) {
|
|
7952
|
+
scopedProbeMisses += 1;
|
|
7953
|
+
if (scopedProbeHits > 0 || scopedProbeWarned) return;
|
|
7954
|
+
scopedProbeWarned = true;
|
|
7955
|
+
ctx.logger.warn("dsh-evolution: sessionScoped is on but the family-tool probe has never matched a session in this process — review injection and skill-usage telemetry are inert for every session seen so far. Check whether the family's model rows are mounted at all: tool-memory / tool-skill-manage are devDependencies of @lmzhen/dsh-evolution-host, so a HOST-ONLY install can never match, and a profile overlay that disables either row has the same effect. Under the layered VARIANT form only a session on the Evolution preset matches, so a miss there just means none has run yet. Next: /evolution doctor reports the scoped rows and this verdict (see INSTALL.md).");
|
|
7956
|
+
}
|
|
7957
|
+
/**
|
|
7674
7958
|
* The one decision every cross-session consumer calls before it acts.
|
|
7675
7959
|
* @param ctx - a context of the runtime.
|
|
7676
7960
|
* @param sessionId - the session the event belongs to.
|
|
@@ -7678,11 +7962,17 @@ function sessionSeesFamilyTools(ctx, sessionId) {
|
|
|
7678
7962
|
* @returns true when the consumer may act on this session. A deployment that did
|
|
7679
7963
|
* not declare session scoping always answers true (the historical behavior);
|
|
7680
7964
|
* a scoped one answers true only for a session that carries the family's model
|
|
7681
|
-
* tools.
|
|
7965
|
+
* tools. Each scoped answer updates the process witness, and the first miss with
|
|
7966
|
+
* no match ever leaves one warn (see {@link noteScopedProbeMiss}).
|
|
7682
7967
|
*/
|
|
7683
7968
|
function sessionAudited(ctx, sessionId, sessionScoped) {
|
|
7684
7969
|
if (sessionScoped !== true) return true;
|
|
7685
|
-
|
|
7970
|
+
if (sessionSeesFamilyTools(ctx, sessionId)) {
|
|
7971
|
+
scopedProbeHits += 1;
|
|
7972
|
+
return true;
|
|
7973
|
+
}
|
|
7974
|
+
noteScopedProbeMiss(ctx);
|
|
7975
|
+
return false;
|
|
7686
7976
|
}
|
|
7687
7977
|
//#endregion
|
|
7688
|
-
export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEAD_LOCK_TAKEOVER_MS, DEFAULT_ARCHIVE_AFTER_DAYS, 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_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_MODEL, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DISPATCH_EVENT_TYPES, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, EMPTY_LOCK_TAKEOVER_MS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FAMILY_SESSION_TOOL_NAMES, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, INSTANCE_KEYS, LOCK_BODY_RE, LOCK_SUFFIX, LOCK_TEAR_TAKEOVER_MS, LOW_QUALITY_THRESHOLD, LostWriteLock, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MAX_TIMER_DELAY_MS, MEMORY_GUIDANCE_SECTION_ORDER, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, NATIVE_CALL_EVENT, NATIVE_RESULT_EVENT, PATTERN_OVERLAP,
|
|
7978
|
+
export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEAD_LOCK_TAKEOVER_MS, DEDUP_MAX_PAIR_COMPARISONS, DEFAULT_ARCHIVE_AFTER_DAYS, 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_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_MODEL, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DISPATCH_EVENT_TYPES, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, EMPTY_LOCK_TAKEOVER_MS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FAMILY_SESSION_TOOL_NAMES, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, INSTANCE_KEYS, LOCK_BODY_RE, LOCK_SUFFIX, LOCK_TEAR_TAKEOVER_MS, LOW_QUALITY_THRESHOLD, LostWriteLock, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MAX_TIMER_DELAY_MS, MEMORY_GUIDANCE_SECTION_ORDER, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, NATIVE_CALL_EVENT, NATIVE_RESULT_EVENT, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, PTC_DISPATCH_EVENT, PTC_DISPATCH_START_EVENT, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILLS_GUIDANCE_SECTION_ORDER, SKILL_ACTION_REQUIRED_FIELDS, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, THREAT_EXEMPTION_HINT, ToolDispatchNormalizer, ToolDispatchPayloadError, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorLifecycleFields, applyCuratorMetaFields, assertDispatchPayload, assertSkillsRootAliasRetired, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, callingScope, claimInstance, clampedNumber, clearReviewChannel, collectReadSkillNames, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, countDispatches, createGateSet, decideTakeover, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldToolDispatches, foldTurn, frontmatterBlock, frontmatterCatalogInvalid, getRecord, instanceClaimKey, instanceClaimedWriteSites, instanceHolder, isAbsent, isCommittedWarning, isMissingPath, isPresent, isProcessAlive, isProgramToolName, isReviewChannelSession, isSkillReadToolName, isSkillToolName, isUnknown, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadRowOverrides, loadSuppressedNames, loadUsage, makeSerialQueue, mapProbe, markAgentCreated, markReviewChannel, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, neutralizePromptVariables, newSkillLibrary, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, persistedWriteSite, persistedWriteSites, probeAbsent, probeList, probeMtime, probePresent, probeReason, probeUnknown, readDispatchSignal, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, releaseInstance, renameWithRetry, renderCuratorReportMarkdown, resolveExecOrigins, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, scopedProbeReport, sessionAudited, sessionSeesFamilyTools, skillReadNameOf, skillsRoot, suppressedFile, sweepReviewChannelSessions, transactIo, transactTaskGuard, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, valueOr, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
|