@lmzhen/dsh-evolution-core 0.3.61 → 0.3.63
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/lib/index.js +289 -87
- package/lib/types/constants.d.ts +7 -0
- package/lib/types/env.d.ts +7 -3
- package/lib/types/skill-health.d.ts +7 -1
- package/lib/types/skill-store.d.ts +62 -1
- package/lib/types/threats.d.ts +5 -3
- package/lib/types/usage.d.ts +16 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -117,7 +117,7 @@ async function renameWithRetry(tmp, target, fn = rename) {
|
|
|
117
117
|
async function writeDurableTmp(target, content, openImpl = open) {
|
|
118
118
|
const tmp = `${target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
119
119
|
try {
|
|
120
|
-
const handle = await openImpl(tmp, "wx");
|
|
120
|
+
const handle = await openImpl(tmp, "wx", process.platform === "win32" ? void 0 : await stat(target).then((value) => value.mode & 511).catch(() => void 0));
|
|
121
121
|
try {
|
|
122
122
|
await handle.writeFile(content, "utf8");
|
|
123
123
|
await handle.sync();
|
|
@@ -312,8 +312,16 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
312
312
|
}).catch(async () => {
|
|
313
313
|
const body = await readFile(lock, "utf8").catch(() => "");
|
|
314
314
|
if (pendingSelfCleanup.size >= 64) {
|
|
315
|
-
|
|
316
|
-
|
|
315
|
+
let droppable;
|
|
316
|
+
for (const candidate of pendingSelfCleanup.keys()) {
|
|
317
|
+
if (candidate === lock) continue;
|
|
318
|
+
if (await readFile(candidate, "utf8").then(() => false, () => true)) {
|
|
319
|
+
droppable = candidate;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const victim = droppable ?? pendingSelfCleanup.keys().next().value;
|
|
324
|
+
if (victim !== void 0) pendingSelfCleanup.delete(victim);
|
|
317
325
|
}
|
|
318
326
|
pendingSelfCleanup.set(lock, body);
|
|
319
327
|
});
|
|
@@ -452,8 +460,9 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
452
460
|
async isSymlink(path) {
|
|
453
461
|
try {
|
|
454
462
|
return (await lstat(path)).isSymbolicLink();
|
|
455
|
-
} catch {
|
|
456
|
-
return null;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (isMissing(error)) return null;
|
|
465
|
+
throw error;
|
|
457
466
|
}
|
|
458
467
|
},
|
|
459
468
|
async mtime(path) {
|
|
@@ -525,7 +534,9 @@ function normalizeUsageRecord(record) {
|
|
|
525
534
|
pinned: bool(raw.pinned, base.pinned),
|
|
526
535
|
archived_at: nullableTimestamp(raw.archived_at) ? raw.archived_at : base.archived_at,
|
|
527
536
|
quality_score: typeof raw.quality_score === "number" && Number.isFinite(raw.quality_score) ? raw.quality_score : void 0,
|
|
528
|
-
quality_warn: typeof raw.quality_warn === "boolean" ? raw.quality_warn : void 0
|
|
537
|
+
quality_warn: typeof raw.quality_warn === "boolean" ? raw.quality_warn : void 0,
|
|
538
|
+
feedback_score: typeof raw.feedback_score === "number" && Number.isFinite(raw.feedback_score) ? raw.feedback_score : void 0,
|
|
539
|
+
feedback_warn: typeof raw.feedback_warn === "boolean" ? raw.feedback_warn : void 0
|
|
529
540
|
};
|
|
530
541
|
}
|
|
531
542
|
/** Parse a raw usage sidecar; malformed content reads as empty (best-effort telemetry). */
|
|
@@ -551,11 +562,14 @@ async function loadUsage(root, io = nodeEvolutionIo()) {
|
|
|
551
562
|
*/
|
|
552
563
|
async function mutateUsage(root, io, task) {
|
|
553
564
|
await transactIo(io, usageFile(root), async (current) => {
|
|
565
|
+
let shapePreserved = false;
|
|
554
566
|
if (current !== null) try {
|
|
555
|
-
JSON.parse(current);
|
|
567
|
+
const probe = JSON.parse(current);
|
|
568
|
+
if (probe === null || Array.isArray(probe) || typeof probe !== "object") shapePreserved = true;
|
|
556
569
|
} catch {
|
|
557
570
|
return current;
|
|
558
571
|
}
|
|
572
|
+
if (shapePreserved) return current;
|
|
559
573
|
const map = parseUsage(current);
|
|
560
574
|
await task(map);
|
|
561
575
|
return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
|
|
@@ -584,6 +598,9 @@ function applyCuratorLifecycleFields(disk, curated) {
|
|
|
584
598
|
* Copy the recomputed meta pair (quality_score/quality_warn + the
|
|
585
599
|
* marker-mirrored pin flag) — refreshed tree-wide each run by design, so a
|
|
586
600
|
* concurrent curator run's lifecycle changes are never reverted by them.
|
|
601
|
+
* P1-1 (v15): the feedback pair (`feedback_score`/`feedback_warn`) is
|
|
602
|
+
* deliberately NOT copied — it is feedback-owned (see the field-ownership
|
|
603
|
+
* contract on {@link UsageRecord}) and must survive curator runs untouched.
|
|
587
604
|
*/
|
|
588
605
|
function applyCuratorMetaFields(disk, curated) {
|
|
589
606
|
disk.quality_score = curated.quality_score;
|
|
@@ -614,7 +631,9 @@ function foldCuratorFields(disk, curated, stateOwned) {
|
|
|
614
631
|
/** Whole-file usage write (V6-37, 0.3.37): this is the ONE path that bypasses
|
|
615
632
|
* the malformed-defense and the transact lock — prefer `mutateUsage` for any
|
|
616
633
|
* read-modify-write so a concurrent writer cannot lose its update and a
|
|
617
|
-
* malformed sidecar stays recoverable. Kept for fixture/test seeding.
|
|
634
|
+
* malformed sidecar stays recoverable. Kept for fixture/test seeding.
|
|
635
|
+
* @internal P3-16 (v14): no production caller (verified by grep); exported for
|
|
636
|
+
* the family's tests only. Do not use it to write the sidecar in new code. */
|
|
618
637
|
async function saveUsage(root, map, io = nodeEvolutionIo()) {
|
|
619
638
|
const obj = Object.fromEntries(map.entries());
|
|
620
639
|
await io.writeText(usageFile(root), JSON.stringify(obj, null, 2));
|
|
@@ -778,6 +797,13 @@ const DEFAULT_USER_CHAR_LIMIT = 1375;
|
|
|
778
797
|
/** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
|
|
779
798
|
const DEFAULT_CONSOLIDATION_FAILURES = 3;
|
|
780
799
|
const DEFAULT_SKILL_CONTENT_CHARS = 1e5;
|
|
800
|
+
/** P3-19 (v14): defaults that were written twice (schema `.default()` AND the
|
|
801
|
+
* clamp fallback literal) now have one home per value. */
|
|
802
|
+
const DEFAULT_REVIEW_TIMEOUT_MS = 12e4;
|
|
803
|
+
const DEFAULT_REVIEW_CONTEXT_MESSAGES = 60;
|
|
804
|
+
const DEFAULT_REVIEW_MESSAGE_CHARS = 2e3;
|
|
805
|
+
const DEFAULT_CURATOR_BOOT_GRACE_SECONDS = 10;
|
|
806
|
+
const DEFAULT_CURATOR_REVIEW_MAX_TOKENS = 2048;
|
|
781
807
|
/** 0.3.17 (S3.10, T-1): control-plane fields a model-facing write call may
|
|
782
808
|
* never carry — single source for plan-validator, evolution-policy and the
|
|
783
809
|
* threat scanner (they used to each hardcode the list). */
|
|
@@ -1000,8 +1026,9 @@ function computeScopeView(usage, config, protectedNames, gates) {
|
|
|
1000
1026
|
if (record.pinned || bundled || suppressed || protectedNames?.has(name) === true || isBuiltin) protectedSet.add(name);
|
|
1001
1027
|
if (lifecycleCandidate(name, record, config, bundled, gateSet, protectedNames)) {
|
|
1002
1028
|
managed.push(name);
|
|
1003
|
-
|
|
1004
|
-
if (record.
|
|
1029
|
+
const warned = record.quality_warn === true || record.feedback_warn === true;
|
|
1030
|
+
if (record.state === "stale" || warned) watched.push(name);
|
|
1031
|
+
if (warned) qualityWarned.push(name);
|
|
1005
1032
|
}
|
|
1006
1033
|
}
|
|
1007
1034
|
return {
|
|
@@ -1028,7 +1055,7 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
1028
1055
|
const age = daysSince(null, record.created_at, now.getTime());
|
|
1029
1056
|
if (record.use_count === 0 && age < config.staleAfterDays) continue;
|
|
1030
1057
|
const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
|
|
1031
|
-
const qualityWarn = record.quality_warn === true;
|
|
1058
|
+
const qualityWarn = record.quality_warn === true || record.feedback_warn === true;
|
|
1032
1059
|
const staleAfterDays = qualityWarn && config.qualityWarnStaleAfterDays !== void 0 ? config.qualityWarnStaleAfterDays : config.staleAfterDays;
|
|
1033
1060
|
if (record.state === "active") {
|
|
1034
1061
|
if (idle >= config.archiveAfterDays) {
|
|
@@ -1043,7 +1070,8 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
1043
1070
|
result.archive.push(name);
|
|
1044
1071
|
} else if (idle >= staleAfterDays) {
|
|
1045
1072
|
record.state = "stale";
|
|
1046
|
-
const
|
|
1073
|
+
const warnSource = record.feedback_warn === true ? "feedback-warn stale" : "quality-warn stale";
|
|
1074
|
+
const reason = qualityWarn ? `idle ${Math.round(idle)}d >= ${warnSource} ${staleAfterDays}d` : `idle ${Math.round(idle)}d >= ${staleAfterDays}d`;
|
|
1047
1075
|
result.transitions.push({
|
|
1048
1076
|
name,
|
|
1049
1077
|
from: "active",
|
|
@@ -1339,10 +1367,14 @@ async function readEvolutionTimeline(io, path) {
|
|
|
1339
1367
|
* reads inside patch YAML `!!js` expressions (session-query path/openAt) stay
|
|
1340
1368
|
* in the profile config evaluation — they are NOT migrated (they are resolved
|
|
1341
1369
|
* at cordis config time, not plugin time) but are documented in the README
|
|
1342
|
-
* env table.
|
|
1370
|
+
* env table. `EVOLUTION_SCOPE` is read by the source installers only
|
|
1371
|
+
* (`packages/scripts/install-layered.mjs`, `packages/test-support/row-contract.ts`),
|
|
1372
|
+
* never by plugin runtime code.
|
|
1373
|
+
*
|
|
1374
|
+
* v14 P3-1: the former `EVOLUTION_ENV_KEYS` export was deleted — nothing read
|
|
1375
|
+
* it, so the "generated env reference" it claimed to source was never
|
|
1376
|
+
* generated (the README table is maintained by hand and now lists every key).
|
|
1343
1377
|
*/
|
|
1344
|
-
/** Plugin-side DSH_EVOLUTION_* keys (config-layer keys are documented separately). */
|
|
1345
|
-
const EVOLUTION_ENV_KEYS = ["DSH_EVOLUTION_ALLOW_ROW_COLLISIONS"];
|
|
1346
1378
|
const ALLOW_ROW_COLLISIONS = "1";
|
|
1347
1379
|
/** N-5 escape: `DSH_EVOLUTION_ALLOW_ROW_COLLISIONS=1` downgrades a delta-row
|
|
1348
1380
|
* collision from fail-loud to warn+keep-both. Any other value (including an
|
|
@@ -1977,7 +2009,7 @@ const PATTERNS = [
|
|
|
1977
2009
|
label: "read_secrets",
|
|
1978
2010
|
category: "exfiltration",
|
|
1979
2011
|
scope: "all",
|
|
1980
|
-
regex: /\bcat\s+[^\n]{0,512}(?:\.env|
|
|
2012
|
+
regex: /\bcat\s+[^\n]{0,512}(?:\.env(?!\w)|(?:\bcredentials\b)|\.netrc|\.pgpass|\.npmrc|\.pypirc)/i
|
|
1981
2013
|
},
|
|
1982
2014
|
{
|
|
1983
2015
|
label: "ssh_backdoor",
|
|
@@ -2027,6 +2059,12 @@ const PATTERNS = [
|
|
|
2027
2059
|
scope: "strict",
|
|
2028
2060
|
regex: /(?:api[_-]?key|token|secret|password)\s*[=:]\s*["'][a-z0-9+/=_-]{20,}["']/i
|
|
2029
2061
|
},
|
|
2062
|
+
{
|
|
2063
|
+
label: "jwt_like_secret",
|
|
2064
|
+
category: "hardcoded_secrets",
|
|
2065
|
+
scope: "strict",
|
|
2066
|
+
regex: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/
|
|
2067
|
+
},
|
|
2030
2068
|
{
|
|
2031
2069
|
label: "private_key_block",
|
|
2032
2070
|
category: "hardcoded_secrets",
|
|
@@ -2060,15 +2098,16 @@ const PATTERN_OVERLAP = 4096;
|
|
|
2060
2098
|
function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
2061
2099
|
const windowSize = clampedNumber(maxScanChars, 65536, { min: 4097 });
|
|
2062
2100
|
const findings = [];
|
|
2063
|
-
|
|
2101
|
+
const excluded = new Set(options.excludeLabels ?? []);
|
|
2102
|
+
if (ZERO_WIDTH_CHARS.test(text) && !excluded.has("unicode_zero_width")) findings.push({
|
|
2064
2103
|
label: "unicode_zero_width",
|
|
2065
2104
|
category: "unicode_obfuscation",
|
|
2066
|
-
scope
|
|
2105
|
+
scope: "all"
|
|
2067
2106
|
});
|
|
2068
|
-
if (BIDI_CHARS.test(text)) findings.push({
|
|
2107
|
+
if (BIDI_CHARS.test(text) && !excluded.has("unicode_bidi_override")) findings.push({
|
|
2069
2108
|
label: "unicode_bidi_override",
|
|
2070
2109
|
category: "unicode_obfuscation",
|
|
2071
|
-
scope
|
|
2110
|
+
scope: "all"
|
|
2072
2111
|
});
|
|
2073
2112
|
const normalized = text.normalize("NFKC");
|
|
2074
2113
|
const windows = [];
|
|
@@ -2077,7 +2116,6 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
|
|
|
2077
2116
|
const step = Math.max(Math.floor(windowSize / 2), 1);
|
|
2078
2117
|
for (let start = 0; start < normalized.length; start += step) windows.push(normalized.slice(start, start + windowSize));
|
|
2079
2118
|
}
|
|
2080
|
-
const excluded = new Set(options.excludeLabels ?? []);
|
|
2081
2119
|
const seen = /* @__PURE__ */ new Set();
|
|
2082
2120
|
for (const window of windows) for (const pattern of PATTERNS) {
|
|
2083
2121
|
if (SCOPE_ORDER[pattern.scope] > SCOPE_ORDER[scope]) continue;
|
|
@@ -2121,9 +2159,11 @@ function scanContentThreats(text, maxScanChars = 65536, options = NO_SCAN_OPTION
|
|
|
2121
2159
|
* V10-03 (P2-18): suffix the SkillLibrary/MemoryStore write gates append to a
|
|
2122
2160
|
* block message — the hit label is already embedded by scanContentThreats /
|
|
2123
2161
|
* scanMemoryThreats, this names the deployable self-heal path so the model
|
|
2124
|
-
* (or operator) can allowlist a known-benign label.
|
|
2125
|
-
*
|
|
2126
|
-
*
|
|
2162
|
+
* (or operator) can allowlist a known-benign label. P2-4 (v14): the
|
|
2163
|
+
* evolution-threat guard channel now carries `threatExemptLabels` too, so its
|
|
2164
|
+
* block message (which embeds the same exemption sentence) is accurate there
|
|
2165
|
+
* as well; this suffix stays store-side because the guard returns the scan
|
|
2166
|
+
* message verbatim.
|
|
2127
2167
|
*/
|
|
2128
2168
|
const THREAT_EXEMPT_HINT = " If this is a legitimate false positive, the deployment can allow its label via the threatExemptLabels store option.";
|
|
2129
2169
|
//#endregion
|
|
@@ -2300,11 +2340,14 @@ var MemoryStore = class {
|
|
|
2300
2340
|
async backupFile(target) {
|
|
2301
2341
|
const path = fileFor(this.root, target);
|
|
2302
2342
|
const backup = `${path}.bak`;
|
|
2343
|
+
const staging = `${backup}.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
2303
2344
|
try {
|
|
2304
|
-
await this.io.remove(
|
|
2305
|
-
await this.io.copy(path,
|
|
2345
|
+
await this.io.remove(staging).catch(() => {});
|
|
2346
|
+
await this.io.copy(path, staging);
|
|
2347
|
+
await this.io.rename(staging, backup);
|
|
2306
2348
|
return backup;
|
|
2307
2349
|
} catch {
|
|
2350
|
+
await this.io.remove(staging).catch(() => {});
|
|
2308
2351
|
return null;
|
|
2309
2352
|
}
|
|
2310
2353
|
}
|
|
@@ -2364,7 +2407,13 @@ var MemoryStore = class {
|
|
|
2364
2407
|
async addCore(target, facts, raw) {
|
|
2365
2408
|
const content = facts.trim();
|
|
2366
2409
|
if (!content) return {
|
|
2367
|
-
result:
|
|
2410
|
+
result: {
|
|
2411
|
+
ok: false,
|
|
2412
|
+
message: "Content cannot be empty.",
|
|
2413
|
+
entries: [],
|
|
2414
|
+
chars: 0,
|
|
2415
|
+
limit: this.limitFor(target)
|
|
2416
|
+
},
|
|
2368
2417
|
write: null
|
|
2369
2418
|
};
|
|
2370
2419
|
if (this.driftFromRaw(target, raw)) {
|
|
@@ -2650,7 +2699,7 @@ var MemoryStore = class {
|
|
|
2650
2699
|
const note = safe.length === entries.length ? "" : ` (${entries.length - safe.length} threat-matched entries filtered)`;
|
|
2651
2700
|
const usage = limit > 0 ? ` [${Math.min(100, Math.floor(body.length * 100 / limit))}% — ${body.length}/${limit} chars]` : "";
|
|
2652
2701
|
parts.push(`## ${label} (${safe.length} entries)${usage}${note}\n${body}`);
|
|
2653
|
-
}
|
|
2702
|
+
} else if (entries.length > 0) parts.push(`## ${label} — ${entries.length} entries withheld by the security scan; none injected`);
|
|
2654
2703
|
}
|
|
2655
2704
|
return parts.join("\n\n");
|
|
2656
2705
|
}
|
|
@@ -2704,25 +2753,31 @@ function contentHash(content) {
|
|
|
2704
2753
|
function parseMutationRecords(raw) {
|
|
2705
2754
|
if (raw === null) return [];
|
|
2706
2755
|
try {
|
|
2707
|
-
|
|
2708
|
-
return (Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.records) ? parsed.records : []).filter((entry) => typeof entry === "object" && entry !== null && typeof entry.skillName === "string" && typeof entry.action === "string" && typeof entry.at === "string");
|
|
2756
|
+
return recordsFromParsed(JSON.parse(raw));
|
|
2709
2757
|
} catch {
|
|
2710
2758
|
return [];
|
|
2711
2759
|
}
|
|
2712
2760
|
}
|
|
2761
|
+
/** Field-level shape guard shared by the parse and the recordMutation write
|
|
2762
|
+
* path (P3/v15: the guard's JSON.parse and this parse used to run twice over
|
|
2763
|
+
* the same bytes inside one transact). */
|
|
2764
|
+
function recordsFromParsed(parsed) {
|
|
2765
|
+
return (Array.isArray(parsed) ? parsed : typeof parsed === "object" && parsed !== null && Array.isArray(parsed.records) ? parsed.records : []).filter((entry) => typeof entry === "object" && entry !== null && typeof entry.skillName === "string" && typeof entry.action === "string" && typeof entry.at === "string");
|
|
2766
|
+
}
|
|
2713
2767
|
async function loadMutations(root, io = nodeEvolutionIo()) {
|
|
2714
2768
|
return parseMutationRecords(await io.readText(mutationsFile(root)));
|
|
2715
2769
|
}
|
|
2716
2770
|
/** Append one record, trim to `cap`, and write atomically (versioned shape). */
|
|
2717
2771
|
async function recordMutation(root, io, record, cap = 500) {
|
|
2718
2772
|
await transactIo(io, mutationsFile(root), (current) => {
|
|
2773
|
+
let parsed = [];
|
|
2719
2774
|
if (current !== null) try {
|
|
2720
|
-
JSON.parse(current);
|
|
2775
|
+
parsed = JSON.parse(current);
|
|
2721
2776
|
} catch {
|
|
2722
2777
|
console.warn(`mutation audit record dropped: ${mutationsFile(root)} is malformed and was not overwritten`);
|
|
2723
2778
|
return current;
|
|
2724
2779
|
}
|
|
2725
|
-
const existing =
|
|
2780
|
+
const existing = recordsFromParsed(parsed);
|
|
2726
2781
|
existing.push(record);
|
|
2727
2782
|
const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
|
|
2728
2783
|
return JSON.stringify({
|
|
@@ -3660,7 +3715,9 @@ const SUPPORT_FILE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
|
3660
3715
|
/** C-18: win32 reserves these stems with ANY extension (`nul.md` hits the
|
|
3661
3716
|
* NUL device), and they are fully inside the charset above — so the reserved
|
|
3662
3717
|
* set is checked on the first-dot prefix as well; the charset close alone
|
|
3663
|
-
* cannot refuse them.
|
|
3718
|
+
* cannot refuse them. Exported single source: `badName` (skill directories,
|
|
3719
|
+
* P2-11/v15) and `validateSupportPath` (support-file stems, C-18) both
|
|
3720
|
+
* consume this one set — a third copy would drift. */
|
|
3664
3721
|
const WIN32_RESERVED_DEVICE_NAMES = new Set([
|
|
3665
3722
|
"con",
|
|
3666
3723
|
"prn",
|
|
@@ -3693,6 +3750,7 @@ function validateSupportPath(filePath) {
|
|
|
3693
3750
|
if (parts.length < 2) return "Provide a file name, not just a directory.";
|
|
3694
3751
|
for (const part of parts.slice(1)) {
|
|
3695
3752
|
if (!SUPPORT_FILE_NAME_RE.test(part)) return `Unsupported file name "${part}" — use lowercase letters, digits, dots, hyphens, and underscores (leading letter or digit).`;
|
|
3753
|
+
if (part.toLowerCase().endsWith(".lock")) return `Unsupported file name "${part}" — the .lock suffix is reserved for the writer-lock protocol.`;
|
|
3696
3754
|
const stem = part.split(".")[0]?.toLowerCase() ?? "";
|
|
3697
3755
|
if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `Unsupported file name "${part}" — a Windows reserved device name.`;
|
|
3698
3756
|
}
|
|
@@ -3982,14 +4040,30 @@ var SkillLibrary = class {
|
|
|
3982
4040
|
dirOf(name) {
|
|
3983
4041
|
return skillDir(this.root, name.trim());
|
|
3984
4042
|
}
|
|
3985
|
-
/**
|
|
3986
|
-
|
|
4043
|
+
/**
|
|
4044
|
+
* Name-format guard for every path-building entry point (P1-1/v14 closed the
|
|
4045
|
+
* one gap: `patch`). Write paths, protection probes and support-file
|
|
4046
|
+
* enumeration all call it before `dirOf`, so no directory path is ever built
|
|
4047
|
+
* from a name that could escape the skills root. `list()` and `snapshotAll()`
|
|
4048
|
+
* are the deliberate exceptions — their names come from `listNames()`, i.e.
|
|
4049
|
+
* from the tree itself, never from caller input.
|
|
4050
|
+
*/
|
|
4051
|
+
badName(name, opts = {}) {
|
|
3987
4052
|
const normalized = name.trim();
|
|
3988
4053
|
if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) return `Invalid skill name "${normalized}". Use lowercase letters, digits, and hyphens (<= ${this.limits.maxNameLength}).`;
|
|
4054
|
+
if (!opts.allowReserved && WIN32_RESERVED_DEVICE_NAMES.has(normalized)) return `"${normalized}" is a Windows reserved device name and cannot be used as a skill name.`;
|
|
3989
4055
|
return null;
|
|
3990
4056
|
}
|
|
4057
|
+
/**
|
|
4058
|
+
* Refusal reason for a write to `rawName`, or null when the write may
|
|
4059
|
+
* proceed: a protection marker on the directory, or an invalid name
|
|
4060
|
+
* (P1-1/v14 — the name guard lives HERE as well as at every entry point, so
|
|
4061
|
+
* no caller can build a path from an unvalidated name).
|
|
4062
|
+
*/
|
|
3991
4063
|
async writeProtection(rawName, origin = "foreground") {
|
|
3992
4064
|
const name = rawName.trim();
|
|
4065
|
+
const badName = this.badName(name);
|
|
4066
|
+
if (badName) return badName;
|
|
3993
4067
|
const dir = this.dirOf(name);
|
|
3994
4068
|
for (const marker of ["bundled", "hub-installed"]) if (await this.io.exists(markerPath(dir, marker))) return marker;
|
|
3995
4069
|
if (origin === "background_review" && await this.io.exists(markerPath(dir, "pinned"))) return "pinned";
|
|
@@ -3997,6 +4071,8 @@ var SkillLibrary = class {
|
|
|
3997
4071
|
}
|
|
3998
4072
|
async deleteProtection(rawName, options = {}) {
|
|
3999
4073
|
const name = rawName.trim();
|
|
4074
|
+
const badName = this.badName(name, { allowReserved: true });
|
|
4075
|
+
if (badName) return badName;
|
|
4000
4076
|
const dir = this.dirOf(name);
|
|
4001
4077
|
const markers = options.allowBundled ? ["hub-installed", "pinned"] : [
|
|
4002
4078
|
"bundled",
|
|
@@ -4008,6 +4084,7 @@ var SkillLibrary = class {
|
|
|
4008
4084
|
}
|
|
4009
4085
|
async isManaged(rawName) {
|
|
4010
4086
|
const name = rawName.trim();
|
|
4087
|
+
if (this.badName(name) !== null) return false;
|
|
4011
4088
|
const dir = this.dirOf(name);
|
|
4012
4089
|
return await this.io.exists(markerPath(dir, "hermes-managed"));
|
|
4013
4090
|
}
|
|
@@ -4051,7 +4128,9 @@ var SkillLibrary = class {
|
|
|
4051
4128
|
* Used by the maintenance enrichment (011 §7) and probe reads.
|
|
4052
4129
|
*/
|
|
4053
4130
|
async listSupportFiles(rawName) {
|
|
4054
|
-
const
|
|
4131
|
+
const name = rawName.trim();
|
|
4132
|
+
if (this.badName(name) !== null) return [];
|
|
4133
|
+
const dir = this.dirOf(name);
|
|
4055
4134
|
let entries;
|
|
4056
4135
|
try {
|
|
4057
4136
|
entries = await this.io.list(dir);
|
|
@@ -4194,7 +4273,21 @@ var SkillLibrary = class {
|
|
|
4194
4273
|
message: `Skill "${normalized}" is protected (${protection}).`
|
|
4195
4274
|
};
|
|
4196
4275
|
const onDisk = finalContent.trimEnd() + "\n";
|
|
4197
|
-
|
|
4276
|
+
const createPath = join(dir, "SKILL.md");
|
|
4277
|
+
let existsAtCommit = false;
|
|
4278
|
+
if (this.transact) await this.transact(this.io, createPath, (current) => {
|
|
4279
|
+
if (current !== null) {
|
|
4280
|
+
existsAtCommit = true;
|
|
4281
|
+
return current;
|
|
4282
|
+
}
|
|
4283
|
+
return onDisk;
|
|
4284
|
+
});
|
|
4285
|
+
else if (await this.io.exists(createPath)) existsAtCommit = true;
|
|
4286
|
+
else await this.io.writeText(createPath, onDisk);
|
|
4287
|
+
if (existsAtCommit) return {
|
|
4288
|
+
ok: false,
|
|
4289
|
+
message: `Skill "${normalized}" already exists.`
|
|
4290
|
+
};
|
|
4198
4291
|
if (origin !== "foreground") await this.io.writeText(markerPath(dir, "hermes-managed"), "");
|
|
4199
4292
|
await this.audit(normalized, "create", null, onDisk, "created");
|
|
4200
4293
|
this.notifyMutation({
|
|
@@ -4214,13 +4307,13 @@ var SkillLibrary = class {
|
|
|
4214
4307
|
return await this.serial(() => this.updateCore(name, content, origin));
|
|
4215
4308
|
}
|
|
4216
4309
|
async updateCore(name, content, origin) {
|
|
4217
|
-
const dir = this.dirOf(name);
|
|
4218
|
-
const path = join(dir, "SKILL.md");
|
|
4219
4310
|
const badName = this.badName(name);
|
|
4220
4311
|
if (badName) return {
|
|
4221
4312
|
ok: false,
|
|
4222
4313
|
message: badName
|
|
4223
4314
|
};
|
|
4315
|
+
const dir = this.dirOf(name);
|
|
4316
|
+
const path = join(dir, "SKILL.md");
|
|
4224
4317
|
const protection = await this.writeProtection(name, origin);
|
|
4225
4318
|
if (protection) return {
|
|
4226
4319
|
ok: false,
|
|
@@ -4295,6 +4388,11 @@ var SkillLibrary = class {
|
|
|
4295
4388
|
return await this.serial(() => this.patchCore(name, oldString, newString, filePath, replaceAll, origin));
|
|
4296
4389
|
}
|
|
4297
4390
|
async patchCore(name, oldString, newString, filePath, replaceAll, origin) {
|
|
4391
|
+
const badName = this.badName(name);
|
|
4392
|
+
if (badName) return {
|
|
4393
|
+
ok: false,
|
|
4394
|
+
message: badName
|
|
4395
|
+
};
|
|
4298
4396
|
const dir = this.dirOf(name);
|
|
4299
4397
|
const skillMd = join(dir, "SKILL.md");
|
|
4300
4398
|
if (!await this.io.exists(skillMd)) return {
|
|
@@ -4333,6 +4431,13 @@ var SkillLibrary = class {
|
|
|
4333
4431
|
},
|
|
4334
4432
|
write: null
|
|
4335
4433
|
};
|
|
4434
|
+
if (oldString === "" || trimPatternBoundaries(oldString) === "") return {
|
|
4435
|
+
result: {
|
|
4436
|
+
ok: false,
|
|
4437
|
+
message: `old_string is empty (or whitespace-only) — a patch of "${name}/${patchLabel}" needs an anchor; use update for a full rewrite.`
|
|
4438
|
+
},
|
|
4439
|
+
write: null
|
|
4440
|
+
};
|
|
4336
4441
|
const patched = fuzzyPatch(md, oldString, newString, replaceAll);
|
|
4337
4442
|
if (patched === null) return {
|
|
4338
4443
|
result: {
|
|
@@ -4428,9 +4533,113 @@ var SkillLibrary = class {
|
|
|
4428
4533
|
};
|
|
4429
4534
|
});
|
|
4430
4535
|
}
|
|
4536
|
+
/**
|
|
4537
|
+
* P2-9 (v15): the destructive directory move shared by archive and
|
|
4538
|
+
* restoreFromArchive — rename first, copy+remove fallback when the backend
|
|
4539
|
+
* cannot rename across media (V5-35), with the E-14 rollback when the
|
|
4540
|
+
* fallback's source removal fails. Returns a failure MESSAGE on a failed
|
|
4541
|
+
* move (caller wraps into a structured result) or undefined on success.
|
|
4542
|
+
*/
|
|
4543
|
+
async moveDir(dir, dest) {
|
|
4544
|
+
try {
|
|
4545
|
+
await this.io.rename(dir, dest);
|
|
4546
|
+
return;
|
|
4547
|
+
} catch {
|
|
4548
|
+
if (await this.io.exists(dest)) return "the destination appeared mid-move (concurrent create or restore); refusing to merge — inspect both trees";
|
|
4549
|
+
try {
|
|
4550
|
+
await this.io.copy(dir, dest);
|
|
4551
|
+
} catch (copyError) {
|
|
4552
|
+
return `the move fell back to copy but failed (${copyError instanceof Error ? copyError.message : String(copyError)}); the tree stays where it is`;
|
|
4553
|
+
}
|
|
4554
|
+
try {
|
|
4555
|
+
await this.io.remove(dir);
|
|
4556
|
+
return;
|
|
4557
|
+
} catch (error) {
|
|
4558
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
4559
|
+
try {
|
|
4560
|
+
await this.io.remove(dest);
|
|
4561
|
+
return `the copy succeeded but the source could not be removed (${reason}); the copied tree was rolled back`;
|
|
4562
|
+
} catch {
|
|
4563
|
+
return `the copy succeeded but the source could not be removed (${reason}) and the copied tree could not be rolled back — the tree now exists in BOTH locations; clean up manually`;
|
|
4564
|
+
}
|
|
4565
|
+
}
|
|
4566
|
+
}
|
|
4567
|
+
}
|
|
4568
|
+
/**
|
|
4569
|
+
* P2 (v16): the write-lock probe for the DESTRUCTIVE MOVERS (archive /
|
|
4570
|
+
* restoreFromArchive). A byte-writer mid-flight is the ghost-generator —
|
|
4571
|
+
* after the move its transact commit re-creates `<dir>/…` (mkdir
|
|
4572
|
+
* recursive) and the tree ends half-archived. The signal is the writer's
|
|
4573
|
+
* own lock file, and its PLACEMENT (inside the moved directory) is why the
|
|
4574
|
+
* mover must PROBE-and-REFUSE instead of acquiring it: an acquired lock
|
|
4575
|
+
* would be renamed into `.archive` with the tree, stranding a phantom live
|
|
4576
|
+
* lock (the v16 audit proved the probe→rename TOCTOU does exactly that,
|
|
4577
|
+
* and restore would later move the residue back into the live root).
|
|
4578
|
+
* Coverage: `SKILL.md.lock` (update/patch of the body) plus one level of
|
|
4579
|
+
* each support dir (write_file's lock sits next to its file). Residual:
|
|
4580
|
+
* NESTED support-subdir locks and the probe→rename TOCTOU itself remain
|
|
4581
|
+
* fail-safe (renameWithRetry rides the write out; the writer's locked
|
|
4582
|
+
* re-read refuses on the moved-away file), and a residue `.lock` from a
|
|
4583
|
+
* CRASHED writer also refuses — correct: inspect, don't archive.
|
|
4584
|
+
*/
|
|
4585
|
+
async hasWriteLock(dir) {
|
|
4586
|
+
const markerLocks = [
|
|
4587
|
+
join(dir, "SKILL.md.lock"),
|
|
4588
|
+
join(dir, ".pinned.lock"),
|
|
4589
|
+
join(dir, ".hermes-managed.lock")
|
|
4590
|
+
];
|
|
4591
|
+
for (const lock of markerLocks) if (await this.isWriterLock(lock)) return true;
|
|
4592
|
+
for (const supportDir of SUPPORT_DIRS) {
|
|
4593
|
+
let entries;
|
|
4594
|
+
try {
|
|
4595
|
+
entries = await this.io.list(join(dir, supportDir));
|
|
4596
|
+
} catch {
|
|
4597
|
+
return true;
|
|
4598
|
+
}
|
|
4599
|
+
for (const entry of entries) {
|
|
4600
|
+
if (!entry.endsWith(".lock")) continue;
|
|
4601
|
+
if (await this.isWriterLock(join(dir, supportDir, entry))) return true;
|
|
4602
|
+
}
|
|
4603
|
+
}
|
|
4604
|
+
return false;
|
|
4605
|
+
}
|
|
4606
|
+
/** P2 (v17): a file only counts as a writer lock when its body has the
|
|
4607
|
+
* `pid:token` shape the io layer writes. User support files legitimately
|
|
4608
|
+
* named `*.lock` (allowed by SUPPORT_FILE_NAME_RE) must not trip the probe
|
|
4609
|
+
* or be swept as residue — the v16 first cut matched on suffix alone,
|
|
4610
|
+
* which permanently refused archiving and deleted user content on restore. */
|
|
4611
|
+
async isWriterLock(lockPath) {
|
|
4612
|
+
const body = await this.io.readText(lockPath).catch(() => null);
|
|
4613
|
+
if (body === null) return false;
|
|
4614
|
+
return /^\d+:[0-9a-f]*$/.test(body.trim());
|
|
4615
|
+
}
|
|
4616
|
+
/** P2 (v16): best-effort removal of lock residue inside a RESTORED tree —
|
|
4617
|
+
* a `.lock` that a pre-restore crash or TOCTOU stranded in `.archive`
|
|
4618
|
+
* cannot have a live writer (restore refuses when the live root is
|
|
4619
|
+
* locked), and if left in place its body (a live pid on a single-host
|
|
4620
|
+
* deployment) structurally closes the writer's self-heal path. */
|
|
4621
|
+
async deleteStrandedLocks(dir) {
|
|
4622
|
+
await this.sweepLockIfStranded(join(dir, "SKILL.md.lock"));
|
|
4623
|
+
for (const supportDir of SUPPORT_DIRS) {
|
|
4624
|
+
let entries = [];
|
|
4625
|
+
try {
|
|
4626
|
+
entries = await this.io.list(join(dir, supportDir));
|
|
4627
|
+
} catch {
|
|
4628
|
+
continue;
|
|
4629
|
+
}
|
|
4630
|
+
for (const entry of entries) if (entry.endsWith(".lock")) await this.sweepLockIfStranded(join(dir, supportDir, entry));
|
|
4631
|
+
}
|
|
4632
|
+
}
|
|
4633
|
+
/** Remove `lockPath` only when its body has the writer-lock `pid:token`
|
|
4634
|
+
* shape; anything else (a user support file) is left untouched. */
|
|
4635
|
+
async sweepLockIfStranded(lockPath) {
|
|
4636
|
+
const body = await this.io.readText(lockPath).catch(() => null);
|
|
4637
|
+
if (body === null || !/^\d+:[0-9a-f]*$/.test(body.trim())) return;
|
|
4638
|
+
await this.io.remove(lockPath).catch(() => {});
|
|
4639
|
+
}
|
|
4431
4640
|
async archive(rawName, options = {}) {
|
|
4432
4641
|
const name = rawName.trim();
|
|
4433
|
-
const badName = this.badName(name);
|
|
4642
|
+
const badName = this.badName(name, { allowReserved: true });
|
|
4434
4643
|
if (badName) return {
|
|
4435
4644
|
ok: false,
|
|
4436
4645
|
message: badName
|
|
@@ -4451,6 +4660,11 @@ var SkillLibrary = class {
|
|
|
4451
4660
|
ok: false,
|
|
4452
4661
|
message: "absorbed_into cannot be the skill being archived (cannot absorb into itself)."
|
|
4453
4662
|
};
|
|
4663
|
+
const intoBad = this.badName(options.absorbedInto.trim());
|
|
4664
|
+
if (intoBad) return {
|
|
4665
|
+
ok: false,
|
|
4666
|
+
message: `absorbed_into: ${intoBad}`
|
|
4667
|
+
};
|
|
4454
4668
|
if (!await this.io.readText(join(this.dirOf(options.absorbedInto), "SKILL.md"))) return {
|
|
4455
4669
|
ok: false,
|
|
4456
4670
|
message: `absorbed_into="${options.absorbedInto}" does not exist.`
|
|
@@ -4469,37 +4683,19 @@ var SkillLibrary = class {
|
|
|
4469
4683
|
message: `Skill "${name}" is a symlink; refusing to archive it.`
|
|
4470
4684
|
};
|
|
4471
4685
|
}
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
};
|
|
4482
|
-
}
|
|
4483
|
-
try {
|
|
4484
|
-
await this.io.remove(dir);
|
|
4485
|
-
} catch (error) {
|
|
4486
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
4487
|
-
try {
|
|
4488
|
-
await this.io.remove(dest);
|
|
4489
|
-
return {
|
|
4490
|
-
ok: false,
|
|
4491
|
-
message: `Archive copy succeeded but the source could not be removed (${reason}); the copied archive was rolled back.`
|
|
4492
|
-
};
|
|
4493
|
-
} catch {
|
|
4494
|
-
return {
|
|
4495
|
-
ok: false,
|
|
4496
|
-
message: `Archive copy succeeded but the source could not be removed (${reason}) and the archive copy could not be rolled back — the skill now exists in BOTH the active root and .archive; clean up manually.`
|
|
4497
|
-
};
|
|
4498
|
-
}
|
|
4499
|
-
}
|
|
4500
|
-
}
|
|
4686
|
+
if (await this.hasWriteLock(dir)) return {
|
|
4687
|
+
ok: false,
|
|
4688
|
+
message: `Skill "${name}" is being written (write lock present); retry archiving once the write completes.`
|
|
4689
|
+
};
|
|
4690
|
+
const moveFailure = await this.moveDir(dir, dest);
|
|
4691
|
+
if (moveFailure !== void 0) return {
|
|
4692
|
+
ok: false,
|
|
4693
|
+
message: `Skill "${name}" archive failed: ${moveFailure}.`
|
|
4694
|
+
};
|
|
4501
4695
|
const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : "Archived by self-evolution curator");
|
|
4502
|
-
|
|
4696
|
+
try {
|
|
4697
|
+
await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
|
|
4698
|
+
} catch {}
|
|
4503
4699
|
await this.audit(name, "archive", md, null, reason);
|
|
4504
4700
|
this.notifyMutation({
|
|
4505
4701
|
action: "archive",
|
|
@@ -4808,7 +5004,7 @@ var SkillLibrary = class {
|
|
|
4808
5004
|
*/
|
|
4809
5005
|
async applyTreeChange(plan) {
|
|
4810
5006
|
const name = plan.name.trim();
|
|
4811
|
-
const badName = this.badName(name);
|
|
5007
|
+
const badName = this.badName(name, { allowReserved: true });
|
|
4812
5008
|
if (badName) return {
|
|
4813
5009
|
ok: false,
|
|
4814
5010
|
message: badName
|
|
@@ -4892,7 +5088,7 @@ var SkillLibrary = class {
|
|
|
4892
5088
|
*/
|
|
4893
5089
|
async restoreFromArchive(rawName) {
|
|
4894
5090
|
const name = rawName.trim();
|
|
4895
|
-
const bad = this.badName(name);
|
|
5091
|
+
const bad = this.badName(name, { allowReserved: true });
|
|
4896
5092
|
if (bad) return {
|
|
4897
5093
|
ok: false,
|
|
4898
5094
|
message: bad
|
|
@@ -4929,20 +5125,18 @@ var SkillLibrary = class {
|
|
|
4929
5125
|
message: `Archived entry "${chosen}" is a symlink; refusing to restore it.`
|
|
4930
5126
|
};
|
|
4931
5127
|
}
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
};
|
|
4943
|
-
}
|
|
4944
|
-
}
|
|
5128
|
+
if (await this.hasWriteLock(dest)) return {
|
|
5129
|
+
ok: false,
|
|
5130
|
+
message: `Skill "${name}" is being written (write lock present); retry restoring once the write completes.`
|
|
5131
|
+
};
|
|
5132
|
+
const moveFailure = await this.moveDir(source, dest);
|
|
5133
|
+
if (moveFailure !== void 0) return {
|
|
5134
|
+
ok: false,
|
|
5135
|
+
message: `Restore of "${name}" from .archive failed: ${moveFailure}`
|
|
5136
|
+
};
|
|
5137
|
+
await this.deleteStrandedLocks(dest);
|
|
4945
5138
|
if (await this.io.exists(join(dest, ".archive-reason"))) await this.io.remove(join(dest, ".archive-reason"));
|
|
5139
|
+
await this.audit(name, "restore", null, await this.io.readText(join(dest, "SKILL.md")).catch(() => null), `restored from ${source}`);
|
|
4946
5140
|
this.notifyMutation({
|
|
4947
5141
|
action: "restore",
|
|
4948
5142
|
name,
|
|
@@ -4959,12 +5153,12 @@ var SkillLibrary = class {
|
|
|
4959
5153
|
return await this.serial(() => this.writeSupportFileCore(name, filePath, content, origin));
|
|
4960
5154
|
}
|
|
4961
5155
|
async writeSupportFileCore(name, filePath, content, origin) {
|
|
4962
|
-
const dir = this.dirOf(name);
|
|
4963
5156
|
const badName = this.badName(name);
|
|
4964
5157
|
if (badName) return {
|
|
4965
5158
|
ok: false,
|
|
4966
5159
|
message: badName
|
|
4967
5160
|
};
|
|
5161
|
+
const dir = this.dirOf(name);
|
|
4968
5162
|
if (!await this.io.exists(join(dir, "SKILL.md"))) return {
|
|
4969
5163
|
ok: false,
|
|
4970
5164
|
message: `Skill "${name}" not found.`
|
|
@@ -5053,6 +5247,10 @@ var SkillLibrary = class {
|
|
|
5053
5247
|
message: `File "${filePath}" not found in skill "${name}".`
|
|
5054
5248
|
};
|
|
5055
5249
|
const before = await this.io.readText(target).catch(() => null);
|
|
5250
|
+
if (before === null) return {
|
|
5251
|
+
ok: false,
|
|
5252
|
+
message: `"${filePath}" is not a readable regular file — remove the files inside it one by one.`
|
|
5253
|
+
};
|
|
5056
5254
|
await this.io.remove(target);
|
|
5057
5255
|
await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
|
|
5058
5256
|
this.notifyMutation({
|
|
@@ -5253,7 +5451,11 @@ var SkillLibrary = class {
|
|
|
5253
5451
|
await this.io.copy(join(snapshotPath, ".archive"), archiveRoot);
|
|
5254
5452
|
} else if (manifest.hasArchive === false) await this.io.remove(archiveRoot);
|
|
5255
5453
|
}
|
|
5454
|
+
for (const entry of await this.io.list(this.root)) {
|
|
5455
|
+
if (entry.startsWith(".")) continue;
|
|
5456
|
+
await this.deleteStrandedLocks(join(this.root, entry));
|
|
5457
|
+
}
|
|
5256
5458
|
}
|
|
5257
5459
|
};
|
|
5258
5460
|
//#endregion
|
|
5259
|
-
export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, 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, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION,
|
|
5461
|
+
export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, 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_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOW_QUALITY_THRESHOLD, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, THREAT_EXEMPT_HINT, WIN32_RESERVED_DEVICE_NAMES, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, clampedNumber, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, pendingSelfCleanup, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, renameWithRetry, renderCuratorReportMarkdown, resolveOrigins, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
|
package/lib/types/constants.d.ts
CHANGED
|
@@ -54,6 +54,13 @@ export declare const DEFAULT_USER_CHAR_LIMIT = 1375;
|
|
|
54
54
|
/** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
|
|
55
55
|
export declare const DEFAULT_CONSOLIDATION_FAILURES = 3;
|
|
56
56
|
export declare const DEFAULT_SKILL_CONTENT_CHARS = 100000;
|
|
57
|
+
/** P3-19 (v14): defaults that were written twice (schema `.default()` AND the
|
|
58
|
+
* clamp fallback literal) now have one home per value. */
|
|
59
|
+
export declare const DEFAULT_REVIEW_TIMEOUT_MS = 120000;
|
|
60
|
+
export declare const DEFAULT_REVIEW_CONTEXT_MESSAGES = 60;
|
|
61
|
+
export declare const DEFAULT_REVIEW_MESSAGE_CHARS = 2000;
|
|
62
|
+
export declare const DEFAULT_CURATOR_BOOT_GRACE_SECONDS = 10;
|
|
63
|
+
export declare const DEFAULT_CURATOR_REVIEW_MAX_TOKENS = 2048;
|
|
57
64
|
/** 0.3.17 (S3.10, T-1): control-plane fields a model-facing write call may
|
|
58
65
|
* never carry — single source for plan-validator, evolution-policy and the
|
|
59
66
|
* threat scanner (they used to each hardcode the list). */
|
package/lib/types/env.d.ts
CHANGED
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* reads inside patch YAML `!!js` expressions (session-query path/openAt) stay
|
|
8
8
|
* in the profile config evaluation — they are NOT migrated (they are resolved
|
|
9
9
|
* at cordis config time, not plugin time) but are documented in the README
|
|
10
|
-
* env table.
|
|
10
|
+
* env table. `EVOLUTION_SCOPE` is read by the source installers only
|
|
11
|
+
* (`packages/scripts/install-layered.mjs`, `packages/test-support/row-contract.ts`),
|
|
12
|
+
* never by plugin runtime code.
|
|
13
|
+
*
|
|
14
|
+
* v14 P3-1: the former `EVOLUTION_ENV_KEYS` export was deleted — nothing read
|
|
15
|
+
* it, so the "generated env reference" it claimed to source was never
|
|
16
|
+
* generated (the README table is maintained by hand and now lists every key).
|
|
11
17
|
*/
|
|
12
|
-
/** Plugin-side DSH_EVOLUTION_* keys (config-layer keys are documented separately). */
|
|
13
|
-
export declare const EVOLUTION_ENV_KEYS: readonly ["DSH_EVOLUTION_ALLOW_ROW_COLLISIONS"];
|
|
14
18
|
/** N-5 escape: `DSH_EVOLUTION_ALLOW_ROW_COLLISIONS=1` downgrades a delta-row
|
|
15
19
|
* collision from fail-loud to warn+keep-both. Any other value (including an
|
|
16
20
|
* EMPTY/whitespace string — a set-but-unset variable) keeps the fail-loud. */
|
|
@@ -47,7 +47,13 @@ export declare const HEALTH_STAMP_RE: RegExp;
|
|
|
47
47
|
*/
|
|
48
48
|
export declare const MIN_STAMP_BODY_CHARS = 2000;
|
|
49
49
|
export type SkillHealthVerdict = 'healthy' | 'warn' | 'needs-restructure';
|
|
50
|
-
/** Facts a caller already has; assessors never do IO.
|
|
50
|
+
/** Facts a caller already has; assessors never do IO.
|
|
51
|
+
*
|
|
52
|
+
* `bodyChars`/`bodyText` are the WHOLE `SKILL.md` text as read from disk
|
|
53
|
+
* (frontmatter included), matching both callers: `SkillLibrary.assessHealth`
|
|
54
|
+
* and `snapshotFromLibrary` feed `read()` verbatim. The field names predate
|
|
55
|
+
* that convention; the thresholds are calibrated against the whole file, so a
|
|
56
|
+
* caller must not strip frontmatter before measuring. */
|
|
51
57
|
export interface SkillHealthSnapshot {
|
|
52
58
|
skillName: string;
|
|
53
59
|
bodyChars: number;
|
|
@@ -228,6 +228,13 @@ export interface AuthoringFeedback {
|
|
|
228
228
|
* truncated or route-poor instead of silently shipping it.
|
|
229
229
|
*/
|
|
230
230
|
export declare function authoringFeedback(frontmatter: Frontmatter): AuthoringFeedback;
|
|
231
|
+
/** C-18: win32 reserves these stems with ANY extension (`nul.md` hits the
|
|
232
|
+
* NUL device), and they are fully inside the charset above — so the reserved
|
|
233
|
+
* set is checked on the first-dot prefix as well; the charset close alone
|
|
234
|
+
* cannot refuse them. Exported single source: `badName` (skill directories,
|
|
235
|
+
* P2-11/v15) and `validateSupportPath` (support-file stems, C-18) both
|
|
236
|
+
* consume this one set — a third copy would drift. */
|
|
237
|
+
export declare const WIN32_RESERVED_DEVICE_NAMES: ReadonlySet<string>;
|
|
231
238
|
export declare class SkillLibrary {
|
|
232
239
|
readonly root: string;
|
|
233
240
|
readonly limits: SkillLimits;
|
|
@@ -280,8 +287,21 @@ export declare class SkillLibrary {
|
|
|
280
287
|
|
|
281
288
|
*/
|
|
282
289
|
private dirOf;
|
|
283
|
-
/**
|
|
290
|
+
/**
|
|
291
|
+
* Name-format guard for every path-building entry point (P1-1/v14 closed the
|
|
292
|
+
* one gap: `patch`). Write paths, protection probes and support-file
|
|
293
|
+
* enumeration all call it before `dirOf`, so no directory path is ever built
|
|
294
|
+
* from a name that could escape the skills root. `list()` and `snapshotAll()`
|
|
295
|
+
* are the deliberate exceptions — their names come from `listNames()`, i.e.
|
|
296
|
+
* from the tree itself, never from caller input.
|
|
297
|
+
*/
|
|
284
298
|
private badName;
|
|
299
|
+
/**
|
|
300
|
+
* Refusal reason for a write to `rawName`, or null when the write may
|
|
301
|
+
* proceed: a protection marker on the directory, or an invalid name
|
|
302
|
+
* (P1-1/v14 — the name guard lives HERE as well as at every entry point, so
|
|
303
|
+
* no caller can build a path from an unvalidated name).
|
|
304
|
+
*/
|
|
285
305
|
writeProtection(rawName: string, origin?: WriteOrigin): Promise<string | null>;
|
|
286
306
|
deleteProtection(rawName: string, options?: {
|
|
287
307
|
allowBundled?: boolean | undefined;
|
|
@@ -326,6 +346,47 @@ export declare class SkillLibrary {
|
|
|
326
346
|
private updateCore;
|
|
327
347
|
patch(rawName: string, oldString: string, newString: string, filePath?: string, replaceAll?: boolean, origin?: WriteOrigin): Promise<SkillActionResult>;
|
|
328
348
|
private patchCore;
|
|
349
|
+
/**
|
|
350
|
+
* P2-9 (v15): the destructive directory move shared by archive and
|
|
351
|
+
* restoreFromArchive — rename first, copy+remove fallback when the backend
|
|
352
|
+
* cannot rename across media (V5-35), with the E-14 rollback when the
|
|
353
|
+
* fallback's source removal fails. Returns a failure MESSAGE on a failed
|
|
354
|
+
* move (caller wraps into a structured result) or undefined on success.
|
|
355
|
+
*/
|
|
356
|
+
private moveDir;
|
|
357
|
+
/**
|
|
358
|
+
* P2 (v16): the write-lock probe for the DESTRUCTIVE MOVERS (archive /
|
|
359
|
+
* restoreFromArchive). A byte-writer mid-flight is the ghost-generator —
|
|
360
|
+
* after the move its transact commit re-creates `<dir>/…` (mkdir
|
|
361
|
+
* recursive) and the tree ends half-archived. The signal is the writer's
|
|
362
|
+
* own lock file, and its PLACEMENT (inside the moved directory) is why the
|
|
363
|
+
* mover must PROBE-and-REFUSE instead of acquiring it: an acquired lock
|
|
364
|
+
* would be renamed into `.archive` with the tree, stranding a phantom live
|
|
365
|
+
* lock (the v16 audit proved the probe→rename TOCTOU does exactly that,
|
|
366
|
+
* and restore would later move the residue back into the live root).
|
|
367
|
+
* Coverage: `SKILL.md.lock` (update/patch of the body) plus one level of
|
|
368
|
+
* each support dir (write_file's lock sits next to its file). Residual:
|
|
369
|
+
* NESTED support-subdir locks and the probe→rename TOCTOU itself remain
|
|
370
|
+
* fail-safe (renameWithRetry rides the write out; the writer's locked
|
|
371
|
+
* re-read refuses on the moved-away file), and a residue `.lock` from a
|
|
372
|
+
* CRASHED writer also refuses — correct: inspect, don't archive.
|
|
373
|
+
*/
|
|
374
|
+
private hasWriteLock;
|
|
375
|
+
/** P2 (v17): a file only counts as a writer lock when its body has the
|
|
376
|
+
* `pid:token` shape the io layer writes. User support files legitimately
|
|
377
|
+
* named `*.lock` (allowed by SUPPORT_FILE_NAME_RE) must not trip the probe
|
|
378
|
+
* or be swept as residue — the v16 first cut matched on suffix alone,
|
|
379
|
+
* which permanently refused archiving and deleted user content on restore. */
|
|
380
|
+
private isWriterLock;
|
|
381
|
+
/** P2 (v16): best-effort removal of lock residue inside a RESTORED tree —
|
|
382
|
+
* a `.lock` that a pre-restore crash or TOCTOU stranded in `.archive`
|
|
383
|
+
* cannot have a live writer (restore refuses when the live root is
|
|
384
|
+
* locked), and if left in place its body (a live pid on a single-host
|
|
385
|
+
* deployment) structurally closes the writer's self-heal path. */
|
|
386
|
+
private deleteStrandedLocks;
|
|
387
|
+
/** Remove `lockPath` only when its body has the writer-lock `pid:token`
|
|
388
|
+
* shape; anything else (a user support file) is left untouched. */
|
|
389
|
+
private sweepLockIfStranded;
|
|
329
390
|
archive(rawName: string, options?: ArchiveOptions): Promise<SkillActionResult>;
|
|
330
391
|
/**
|
|
331
392
|
* Merge the bodies of `sources` into `target` and archive the sources with
|
package/lib/types/threats.d.ts
CHANGED
|
@@ -52,9 +52,11 @@ export declare function scanContentThreats(text: string, maxScanChars?: number,
|
|
|
52
52
|
* V10-03 (P2-18): suffix the SkillLibrary/MemoryStore write gates append to a
|
|
53
53
|
* block message — the hit label is already embedded by scanContentThreats /
|
|
54
54
|
* scanMemoryThreats, this names the deployable self-heal path so the model
|
|
55
|
-
* (or operator) can allowlist a known-benign label.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* (or operator) can allowlist a known-benign label. P2-4 (v14): the
|
|
56
|
+
* evolution-threat guard channel now carries `threatExemptLabels` too, so its
|
|
57
|
+
* block message (which embeds the same exemption sentence) is accurate there
|
|
58
|
+
* as well; this suffix stays store-side because the guard returns the scan
|
|
59
|
+
* message verbatim.
|
|
58
60
|
*/
|
|
59
61
|
export declare const THREAT_EXEMPT_HINT = " If this is a legitimate false positive, the deployment can allow its label via the threatExemptLabels store option.";
|
|
60
62
|
//# sourceMappingURL=threats.d.ts.map
|
package/lib/types/usage.d.ts
CHANGED
|
@@ -18,6 +18,16 @@ export interface UsageRecord {
|
|
|
18
18
|
archived_at: string | null;
|
|
19
19
|
quality_score?: number | undefined;
|
|
20
20
|
quality_warn?: boolean | undefined;
|
|
21
|
+
/** P1-1 (v15): feedback-owned quality signal. Field ownership contract —
|
|
22
|
+
* `quality_score`/`quality_warn` are written ONLY by the curator's
|
|
23
|
+
* six-factor `scoreTree`; `feedback_score`/`feedback_warn` are written ONLY
|
|
24
|
+
* by the feedback channel (`SkillUsageRegistry.setFeedbackQuality`);
|
|
25
|
+
* `foldCuratorFields` refreshes the quality_* pair tree-wide and must never
|
|
26
|
+
* touch feedback_*. The lifecycle engine and the scope view read the UNION
|
|
27
|
+
* of both warn flags, which is what makes negative feedback decision-
|
|
28
|
+
* relevant again. */
|
|
29
|
+
feedback_score?: number | undefined;
|
|
30
|
+
feedback_warn?: boolean | undefined;
|
|
21
31
|
}
|
|
22
32
|
export type UsageMap = Map<string, UsageRecord>;
|
|
23
33
|
export declare function usageFile(root: string): string;
|
|
@@ -60,6 +70,9 @@ export declare function applyCuratorLifecycleFields(disk: UsageRecord, curated:
|
|
|
60
70
|
* Copy the recomputed meta pair (quality_score/quality_warn + the
|
|
61
71
|
* marker-mirrored pin flag) — refreshed tree-wide each run by design, so a
|
|
62
72
|
* concurrent curator run's lifecycle changes are never reverted by them.
|
|
73
|
+
* P1-1 (v15): the feedback pair (`feedback_score`/`feedback_warn`) is
|
|
74
|
+
* deliberately NOT copied — it is feedback-owned (see the field-ownership
|
|
75
|
+
* contract on {@link UsageRecord}) and must survive curator runs untouched.
|
|
63
76
|
*/
|
|
64
77
|
export declare function applyCuratorMetaFields(disk: UsageRecord, curated: UsageRecord): void;
|
|
65
78
|
/**
|
|
@@ -76,7 +89,9 @@ export declare function foldCuratorFields(disk: UsageMap, curated: UsageMap, sta
|
|
|
76
89
|
/** Whole-file usage write (V6-37, 0.3.37): this is the ONE path that bypasses
|
|
77
90
|
* the malformed-defense and the transact lock — prefer `mutateUsage` for any
|
|
78
91
|
* read-modify-write so a concurrent writer cannot lose its update and a
|
|
79
|
-
* malformed sidecar stays recoverable. Kept for fixture/test seeding.
|
|
92
|
+
* malformed sidecar stays recoverable. Kept for fixture/test seeding.
|
|
93
|
+
* @internal P3-16 (v14): no production caller (verified by grep); exported for
|
|
94
|
+
* the family's tests only. Do not use it to write the sidecar in new code. */
|
|
80
95
|
export declare function saveUsage(root: string, map: UsageMap, io?: EvolutionIoLike): Promise<void>;
|
|
81
96
|
export declare function getRecord(map: UsageMap, name: string): UsageRecord;
|
|
82
97
|
export declare function bumpView(map: UsageMap, name: string, when?: Date): void;
|
package/package.json
CHANGED