@lmzhen/dsh-evolution-core 0.3.63 → 0.3.65
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 +14 -12
- package/lib/index.js +460 -153
- package/lib/types/constants.d.ts +10 -1
- package/lib/types/events.d.ts +3 -1
- package/lib/types/evolution-events.d.ts +11 -0
- package/lib/types/io.d.ts +15 -0
- package/lib/types/memory-store.d.ts +4 -0
- package/lib/types/quality.d.ts +2 -2
- package/lib/types/skill-store.d.ts +47 -14
- package/lib/types/threats.d.ts +16 -4
- package/lib/types/usage.d.ts +8 -2
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -156,12 +156,41 @@ async function fsyncDirectory(path) {
|
|
|
156
156
|
async function commitTmp(tmp, target) {
|
|
157
157
|
try {
|
|
158
158
|
await renameWithRetry(tmp, target);
|
|
159
|
-
await fsyncDirectory(dirname(target));
|
|
160
159
|
} catch (error) {
|
|
161
160
|
await rm(tmp, { force: true }).catch(() => {});
|
|
162
161
|
throw error;
|
|
163
162
|
}
|
|
163
|
+
try {
|
|
164
|
+
await fsyncDirectory(dirname(target));
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw Object.assign(/* @__PURE__ */ new Error(`commitTmp: ${target} was renamed but the directory fsync failed: ${error instanceof Error ? error.message : String(error)}`), {
|
|
167
|
+
committed: true,
|
|
168
|
+
cause: error
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* True when the pid is alive (EPERM = alive but unowned; ESRCH = gone).
|
|
174
|
+
* V18 single source: the node backend's lock takeover and SkillLibrary's
|
|
175
|
+
* stranded-lock sweep must use the same liveness rule.
|
|
176
|
+
*/
|
|
177
|
+
function isProcessAlive(pid) {
|
|
178
|
+
try {
|
|
179
|
+
process.kill(pid, 0);
|
|
180
|
+
return true;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return error?.code === "EPERM";
|
|
183
|
+
}
|
|
164
184
|
}
|
|
185
|
+
/** F-17 (v18): the write-lock protocol is a cross-module contract — the lock
|
|
186
|
+
* file is `<target>.lock` and its body is `<pid>:<token>`. This module creates
|
|
187
|
+
* them (`withWriteLock`) and `skill-store`'s probes/sweepers parse them, so both
|
|
188
|
+
* consume these two constants instead of repeating the literals. */
|
|
189
|
+
const LOCK_SUFFIX = ".lock";
|
|
190
|
+
/** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
|
|
191
|
+
* torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
|
|
192
|
+
* the pid part is intact, which is what the takeover probe needs. */
|
|
193
|
+
const LOCK_BODY_RE = /^\d+:[0-9a-f]*$/;
|
|
165
194
|
/**
|
|
166
195
|
* Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
|
|
167
196
|
* (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
|
|
@@ -174,15 +203,8 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
174
203
|
const code = error?.code;
|
|
175
204
|
return code === "ENOENT" || code === "ENOTDIR";
|
|
176
205
|
};
|
|
177
|
-
/** True when the pid is alive (
|
|
178
|
-
const isAlive =
|
|
179
|
-
try {
|
|
180
|
-
process.kill(pid, 0);
|
|
181
|
-
return true;
|
|
182
|
-
} catch (error) {
|
|
183
|
-
return error?.code === "EPERM";
|
|
184
|
-
}
|
|
185
|
-
};
|
|
206
|
+
/** True when the pid is alive (single source: `isProcessAlive`). */
|
|
207
|
+
const isAlive = isProcessAlive;
|
|
186
208
|
/**
|
|
187
209
|
* V10-07 (P2-1): age threshold for taking over a lock whose body is TORN
|
|
188
210
|
* (non-empty, but the pid prefix does not parse to a positive integer). 1h:
|
|
@@ -238,7 +260,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
238
260
|
* forever.
|
|
239
261
|
*/
|
|
240
262
|
const withWriteLock = async (path, task) => {
|
|
241
|
-
const lock = `${path}
|
|
263
|
+
const lock = `${path}${LOCK_SUFFIX}`;
|
|
242
264
|
let myClaim = "";
|
|
243
265
|
for (let attempt = 0; attempt < lockAttempts; attempt += 1) {
|
|
244
266
|
let lockHandle = null;
|
|
@@ -343,40 +365,41 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
343
365
|
return;
|
|
344
366
|
}
|
|
345
367
|
const prefix = `${base}.`;
|
|
346
|
-
const lockName = `${base}
|
|
368
|
+
const lockName = `${base}${LOCK_SUFFIX}`;
|
|
347
369
|
const ticketName = `${lockName}.next`;
|
|
348
370
|
const CORRUPT_SWEEP_AGE_MS = 168 * 36e5;
|
|
349
371
|
for (const name of entries) {
|
|
350
372
|
if (!name.startsWith(prefix) || name === lockName) continue;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (dead || old) await rm(ticketPath, { force: true });
|
|
361
|
-
} catch {}
|
|
362
|
-
continue;
|
|
363
|
-
}
|
|
364
|
-
if (name.includes(".corrupt")) {
|
|
365
|
-
const corruptPath = join(dir, name);
|
|
366
|
-
try {
|
|
367
|
-
const st = await stat(corruptPath);
|
|
368
|
-
if (Date.now() - st.mtimeMs > CORRUPT_SWEEP_AGE_MS) await rm(corruptPath, { force: true });
|
|
369
|
-
} catch {}
|
|
370
|
-
}
|
|
373
|
+
const tmpMatch = /^(.*)\.(\d+)\.([0-9a-f]+)\.tmp$/.exec(name);
|
|
374
|
+
if (tmpMatch !== null && tmpMatch[1] === base) {
|
|
375
|
+
const tmpPath = join(dir, name);
|
|
376
|
+
const holder = Number(tmpMatch[2]);
|
|
377
|
+
try {
|
|
378
|
+
const st = await stat(tmpPath);
|
|
379
|
+
const deadHolder = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
|
|
380
|
+
if (holder === process.pid || Date.now() - st.mtimeMs > 36e5 && deadHolder) await rm(tmpPath, { force: true });
|
|
381
|
+
} catch {}
|
|
371
382
|
continue;
|
|
372
383
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
384
|
+
if (name === ticketName) {
|
|
385
|
+
const ticketPath = join(dir, name);
|
|
386
|
+
try {
|
|
387
|
+
const body = await readFile(ticketPath, "utf8").catch(() => "");
|
|
388
|
+
const holder = Number(body.split(":")[0] ?? "");
|
|
389
|
+
const st = await stat(ticketPath);
|
|
390
|
+
const dead = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
|
|
391
|
+
const old = Date.now() - st.mtimeMs > 1e3;
|
|
392
|
+
if (dead || old) await rm(ticketPath, { force: true });
|
|
393
|
+
} catch {}
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (/\.corrupt(\.\d+)?$/.test(name)) {
|
|
397
|
+
const corruptPath = join(dir, name);
|
|
398
|
+
try {
|
|
399
|
+
const st = await stat(corruptPath);
|
|
400
|
+
if (Date.now() - st.mtimeMs > CORRUPT_SWEEP_AGE_MS) await rm(corruptPath, { force: true });
|
|
401
|
+
} catch {}
|
|
402
|
+
}
|
|
380
403
|
}
|
|
381
404
|
};
|
|
382
405
|
return {
|
|
@@ -553,24 +576,30 @@ function parseUsage(raw) {
|
|
|
553
576
|
async function loadUsage(root, io = nodeEvolutionIo()) {
|
|
554
577
|
return parseUsage(await io.readText(usageFile(root)));
|
|
555
578
|
}
|
|
556
|
-
|
|
557
|
-
* Atomic read-modify-write on the usage sidecar (rc.50 P2-2): `task` receives
|
|
558
|
-
* the map parsed from the current on-disk state and may mutate it; the result
|
|
559
|
-
* is persisted inside the same transact so a second process sharing DSH_HOME
|
|
560
|
-
* cannot interleave its RMW and lose a counter update. Callers keep their own
|
|
561
|
-
* single-process serialize chain as the second layer.
|
|
562
|
-
*/
|
|
563
|
-
async function mutateUsage(root, io, task) {
|
|
579
|
+
async function mutateUsage(root, io, task, options = {}) {
|
|
564
580
|
await transactIo(io, usageFile(root), async (current) => {
|
|
565
581
|
let shapePreserved = false;
|
|
582
|
+
let recovered = null;
|
|
566
583
|
if (current !== null) try {
|
|
567
584
|
const probe = JSON.parse(current);
|
|
568
585
|
if (probe === null || Array.isArray(probe) || typeof probe !== "object") shapePreserved = true;
|
|
586
|
+
else {
|
|
587
|
+
const record = probe;
|
|
588
|
+
const isMalformed = (value) => value === null || typeof value !== "object" || Array.isArray(value);
|
|
589
|
+
if (typeof record.version === "number" && record.version > 1) shapePreserved = true;
|
|
590
|
+
else if (Object.values(record).some(isMalformed)) {
|
|
591
|
+
const bad = Object.keys(record).filter((key) => isMalformed(record[key]));
|
|
592
|
+
const corruptPath = `${usageFile(root)}.corrupt`;
|
|
593
|
+
await io.writeText(corruptPath, current).catch(() => {});
|
|
594
|
+
recovered = JSON.stringify(Object.fromEntries(Object.entries(record).filter(([, value]) => !isMalformed(value))));
|
|
595
|
+
options.onQuarantine?.(`usage sidecar ${usageFile(root)} carried ${bad.length} malformed entr${bad.length === 1 ? "y" : "ies"} (${bad.slice(0, 5).join(", ")}); the original bytes were copied to ${corruptPath} and the remaining entries continue to be served`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
569
598
|
} catch {
|
|
570
599
|
return current;
|
|
571
600
|
}
|
|
572
601
|
if (shapePreserved) return current;
|
|
573
|
-
const map = parseUsage(current);
|
|
602
|
+
const map = parseUsage(recovered ?? current);
|
|
574
603
|
await task(map);
|
|
575
604
|
return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
|
|
576
605
|
});
|
|
@@ -617,7 +646,8 @@ function applyCuratorMetaFields(disk, curated) {
|
|
|
617
646
|
* transitioned — a concurrent curator run's archive/restore is never reverted
|
|
618
647
|
* by a stale snapshot; without it both pairs apply everywhere.
|
|
619
648
|
*/
|
|
620
|
-
function foldCuratorFields(disk, curated, stateOwned) {
|
|
649
|
+
function foldCuratorFields(disk, curated, stateOwned, runStartStates) {
|
|
650
|
+
const skipped = [];
|
|
621
651
|
for (const [name, record] of curated) {
|
|
622
652
|
const diskRecord = disk.get(name);
|
|
623
653
|
if (!diskRecord) {
|
|
@@ -625,8 +655,16 @@ function foldCuratorFields(disk, curated, stateOwned) {
|
|
|
625
655
|
continue;
|
|
626
656
|
}
|
|
627
657
|
applyCuratorMetaFields(diskRecord, record);
|
|
628
|
-
if (stateOwned === void 0 || stateOwned.has(name))
|
|
658
|
+
if (stateOwned === void 0 || stateOwned.has(name)) {
|
|
659
|
+
const expected = runStartStates?.get(name);
|
|
660
|
+
if (expected !== void 0 && diskRecord.state !== expected) {
|
|
661
|
+
skipped.push(name);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
applyCuratorLifecycleFields(diskRecord, record);
|
|
665
|
+
}
|
|
629
666
|
}
|
|
667
|
+
return skipped;
|
|
630
668
|
}
|
|
631
669
|
/** Whole-file usage write (V6-37, 0.3.37): this is the ONE path that bypasses
|
|
632
670
|
* the malformed-defense and the transact lock — prefer `mutateUsage` for any
|
|
@@ -757,8 +795,14 @@ async function updateSuppressedNames(root, io, task) {
|
|
|
757
795
|
* threshold, which are intentionally left where they are used.
|
|
758
796
|
* @module @lmzhen/dsh-evolution-core
|
|
759
797
|
*/
|
|
760
|
-
/** Skill frontmatter `name` validated for the file name (lowercase + hyphen).
|
|
761
|
-
|
|
798
|
+
/** Skill frontmatter `name` validated for the file name (lowercase + hyphen).
|
|
799
|
+
* 计划 B-4 (v18): tightened to the UPSTREAM `SKILL_NAME` shape
|
|
800
|
+
* (`/^[a-z0-9]+(?:-[a-z0-9]+)*$/`, packages/skill/skill/src/index.ts:20). The
|
|
801
|
+
* old form admitted trailing/consecutive hyphens, which upstream
|
|
802
|
+
* `validateCandidate` throws on — and that throw aborts the WHOLE `ctx.skills`
|
|
803
|
+
* collection. The catalog provider still filters such legacy tree entries so
|
|
804
|
+
* an existing tree cannot break a session. */
|
|
805
|
+
const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
762
806
|
/** Allowed skill support-file subdirectories (path-traversal boundary). */
|
|
763
807
|
const SUPPORT_DIRS = [
|
|
764
808
|
"references",
|
|
@@ -796,7 +840,10 @@ const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
|
|
|
796
840
|
const DEFAULT_USER_CHAR_LIMIT = 1375;
|
|
797
841
|
/** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
|
|
798
842
|
const DEFAULT_CONSOLIDATION_FAILURES = 3;
|
|
799
|
-
|
|
843
|
+
/** F-20 (v18): the authored-body budget and the hard ceiling are the same
|
|
844
|
+
* number today. Derive it so a future divergence is one edit, not two names
|
|
845
|
+
* that silently disagree. */
|
|
846
|
+
const DEFAULT_SKILL_CONTENT_CHARS = MAX_SKILL_CONTENT_CHARS;
|
|
800
847
|
/** P3-19 (v14): defaults that were written twice (schema `.default()` AND the
|
|
801
848
|
* clamp fallback literal) now have one home per value. */
|
|
802
849
|
const DEFAULT_REVIEW_TIMEOUT_MS = 12e4;
|
|
@@ -1040,7 +1087,10 @@ function computeScopeView(usage, config, protectedNames, gates) {
|
|
|
1040
1087
|
};
|
|
1041
1088
|
}
|
|
1042
1089
|
function daysSince(iso, created, now) {
|
|
1043
|
-
|
|
1090
|
+
const anchor = iso ?? created;
|
|
1091
|
+
const t = Date.parse(anchor);
|
|
1092
|
+
if (!Number.isFinite(t)) return 0;
|
|
1093
|
+
return (now - t) / 864e5;
|
|
1044
1094
|
}
|
|
1045
1095
|
function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date(), gates, protectedNames) {
|
|
1046
1096
|
const result = {
|
|
@@ -1053,9 +1103,9 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
1053
1103
|
for (const [name, record] of usage) {
|
|
1054
1104
|
if (!lifecycleCandidate(name, record, config, config.bundledNames?.has(name) === true, gateSet, protectedNames)) continue;
|
|
1055
1105
|
const age = daysSince(null, record.created_at, now.getTime());
|
|
1056
|
-
if (record.use_count === 0 && age < config.staleAfterDays) continue;
|
|
1057
|
-
const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
|
|
1058
1106
|
const qualityWarn = record.quality_warn === true || record.feedback_warn === true;
|
|
1107
|
+
if (record.use_count + record.view_count === 0 && !qualityWarn && age < config.staleAfterDays) continue;
|
|
1108
|
+
const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
|
|
1059
1109
|
const staleAfterDays = qualityWarn && config.qualityWarnStaleAfterDays !== void 0 ? config.qualityWarnStaleAfterDays : config.staleAfterDays;
|
|
1060
1110
|
if (record.state === "active") {
|
|
1061
1111
|
if (idle >= config.archiveAfterDays) {
|
|
@@ -1148,6 +1198,28 @@ const EVENT_ARCHIVE_RE = /^events-(\d+)\.json$/;
|
|
|
1148
1198
|
function eventsFile(home) {
|
|
1149
1199
|
return join(home, "evolution", "events.json");
|
|
1150
1200
|
}
|
|
1201
|
+
/** I-5 (v18): one lightweight description of the durable event payload
|
|
1202
|
+
* contract. The log is a FILE boundary (a host, a script or an older version
|
|
1203
|
+
* can write it), so `appendEvolutionEvent` refuses a record no consumer can
|
|
1204
|
+
* fold instead of persisting it and failing silently later. The process event
|
|
1205
|
+
* bus stays unvalidated — that is a typed same-process boundary.
|
|
1206
|
+
* @param event - the candidate event record.
|
|
1207
|
+
* @returns a human-readable issue, or null when the record is well-formed.
|
|
1208
|
+
*/
|
|
1209
|
+
function evolutionEventPayloadIssue(event) {
|
|
1210
|
+
const type = event.type;
|
|
1211
|
+
if (typeof type !== "string") return `unknown event type "${String(type)}"`;
|
|
1212
|
+
switch (type) {
|
|
1213
|
+
case "feedback":
|
|
1214
|
+
if (event.kind !== "skill" && event.kind !== "session") return "feedback event requires kind skill|session";
|
|
1215
|
+
if (event.rating !== "positive" && event.rating !== "negative") return "feedback event requires rating positive|negative";
|
|
1216
|
+
return null;
|
|
1217
|
+
case "maintain": return typeof event.runId === "string" ? null : "maintain event requires runId";
|
|
1218
|
+
case "learn":
|
|
1219
|
+
case "usage": return null;
|
|
1220
|
+
default: return `unknown event type "${type}"`;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1151
1223
|
function isEventRecord(event) {
|
|
1152
1224
|
const seq = event?.seq;
|
|
1153
1225
|
return typeof event === "object" && event !== null && typeof seq === "number" && Number.isFinite(seq);
|
|
@@ -1220,6 +1292,8 @@ async function listEventArchives(io, path) {
|
|
|
1220
1292
|
* event can never shadow an archived one in the seq-deduped timeline.
|
|
1221
1293
|
*/
|
|
1222
1294
|
async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
|
|
1295
|
+
const issue = evolutionEventPayloadIssue(event);
|
|
1296
|
+
if (issue !== null) throw new Error(`evolution event refused: ${issue}`);
|
|
1223
1297
|
let assigned = 0;
|
|
1224
1298
|
let refuseMessage = "";
|
|
1225
1299
|
let parsedBody = null;
|
|
@@ -1265,7 +1339,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
|
|
|
1265
1339
|
* one-event rotate would archive everything and restart seqs at 1).
|
|
1266
1340
|
*/
|
|
1267
1341
|
async function rotateIfDue(io, path, events, rotateAt) {
|
|
1268
|
-
if (rotateAt < 2 || events.length < rotateAt) return events;
|
|
1342
|
+
if (!Number.isFinite(rotateAt) || rotateAt < 2 || events.length < rotateAt) return events;
|
|
1269
1343
|
const mid = Math.ceil(events.length / 2);
|
|
1270
1344
|
const head = events.slice(0, mid);
|
|
1271
1345
|
const tail = events.slice(mid);
|
|
@@ -1903,9 +1977,16 @@ function clampedNumber(value, fallback, opts) {
|
|
|
1903
1977
|
* Threat scanning for agent-authored memory and skill content.
|
|
1904
1978
|
*
|
|
1905
1979
|
* Ported as a small, dependency-free subset of Hermes Agent's
|
|
1906
|
-
* `tools/threat_patterns.py` + hermes-claw `threats.ts`.
|
|
1907
|
-
*
|
|
1908
|
-
*
|
|
1980
|
+
* `tools/threat_patterns.py` + hermes-claw `threats.ts`.
|
|
1981
|
+
*
|
|
1982
|
+
* Policy (P1-1, v19): a finding either BLOCKS the write or only REPORTS.
|
|
1983
|
+
* Blocking is reserved for shapes with no legitimate use in stored knowledge
|
|
1984
|
+
* (prompt-injection phrasing, credential exfiltration, the invisible-character
|
|
1985
|
+
* smuggling core). Typography and presentation characters that are legitimate
|
|
1986
|
+
* in ordinary prose — variation selectors (❤️), zero-width non-joiner, soft
|
|
1987
|
+
* hyphen from PDF paste, Arabic letter mark, Mongolian vowel separator — are
|
|
1988
|
+
* REPORT findings: they stay visible to operators and tests but never reject a
|
|
1989
|
+
* write. Blocking them turned every emoji into a security event.
|
|
1909
1990
|
*/
|
|
1910
1991
|
const FILLER = String.raw`(?:\w+\s+){0,8}`;
|
|
1911
1992
|
const PATTERNS = [
|
|
@@ -1919,7 +2000,7 @@ const PATTERNS = [
|
|
|
1919
2000
|
label: "disregard_rules",
|
|
1920
2001
|
category: "prompt_injection",
|
|
1921
2002
|
scope: "all",
|
|
1922
|
-
regex:
|
|
2003
|
+
regex: new RegExp(String.raw`disregard\s+${FILLER}(?:your|all|any)\s+${FILLER}(?:instructions|rules|guidelines)`, "i")
|
|
1923
2004
|
},
|
|
1924
2005
|
{
|
|
1925
2006
|
label: "system_prompt_override",
|
|
@@ -2072,7 +2153,9 @@ const PATTERNS = [
|
|
|
2072
2153
|
regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/
|
|
2073
2154
|
}
|
|
2074
2155
|
];
|
|
2075
|
-
const ZERO_WIDTH_CHARS =
|
|
2156
|
+
const ZERO_WIDTH_CHARS = new RegExp(`[\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}]`, "u");
|
|
2157
|
+
const ZWJ_OUTSIDE_EMOJI = /(?<!\p{Extended_Pictographic})\u200d(?!\p{Extended_Pictographic})/u;
|
|
2158
|
+
const TYPOGRAPHY_CHARS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/;
|
|
2076
2159
|
const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
|
|
2077
2160
|
const SCOPE_ORDER = {
|
|
2078
2161
|
all: 1,
|
|
@@ -2099,11 +2182,17 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
|
|
|
2099
2182
|
const windowSize = clampedNumber(maxScanChars, 65536, { min: 4097 });
|
|
2100
2183
|
const findings = [];
|
|
2101
2184
|
const excluded = new Set(options.excludeLabels ?? []);
|
|
2102
|
-
if (ZERO_WIDTH_CHARS.test(text) && !excluded.has("unicode_zero_width")) findings.push({
|
|
2185
|
+
if ((ZERO_WIDTH_CHARS.test(text) || ZWJ_OUTSIDE_EMOJI.test(text)) && !excluded.has("unicode_zero_width")) findings.push({
|
|
2103
2186
|
label: "unicode_zero_width",
|
|
2104
2187
|
category: "unicode_obfuscation",
|
|
2105
2188
|
scope: "all"
|
|
2106
2189
|
});
|
|
2190
|
+
if (TYPOGRAPHY_CHARS.test(text) && !excluded.has("unicode_typography")) findings.push({
|
|
2191
|
+
label: "unicode_typography",
|
|
2192
|
+
category: "unicode_obfuscation",
|
|
2193
|
+
scope: "all",
|
|
2194
|
+
severity: "report"
|
|
2195
|
+
});
|
|
2107
2196
|
if (BIDI_CHARS.test(text) && !excluded.has("unicode_bidi_override")) findings.push({
|
|
2108
2197
|
label: "unicode_bidi_override",
|
|
2109
2198
|
category: "unicode_obfuscation",
|
|
@@ -2132,11 +2221,13 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
|
|
|
2132
2221
|
}
|
|
2133
2222
|
return findings;
|
|
2134
2223
|
}
|
|
2135
|
-
/** Blocking policy:
|
|
2224
|
+
/** Blocking policy (P1-1, v19): a finding blocks unless it is explicitly
|
|
2225
|
+
* `report`-only. Pattern findings carry no severity and therefore block as
|
|
2226
|
+
* before. */
|
|
2136
2227
|
function evaluateThreat(text, scope = "strict", maxScanChars = 65536, options = NO_SCAN_OPTIONS) {
|
|
2137
2228
|
const findings = scanThreats(text, scope, maxScanChars, options);
|
|
2138
2229
|
return {
|
|
2139
|
-
blocked: findings.
|
|
2230
|
+
blocked: findings.some((finding) => finding.severity !== "report"),
|
|
2140
2231
|
findings
|
|
2141
2232
|
};
|
|
2142
2233
|
}
|
|
@@ -2262,12 +2353,17 @@ var MemoryStore = class {
|
|
|
2262
2353
|
/** V10-03 (P2-18): the strict-scan write gate. A block message names the hit
|
|
2263
2354
|
* label (scanMemoryThreats already embeds it) plus the self-heal hint. */
|
|
2264
2355
|
memoryThreatBlock(text) {
|
|
2265
|
-
|
|
2266
|
-
return threat === null ? null : threat + THREAT_EXEMPT_HINT;
|
|
2356
|
+
return scanMemoryThreats(text, void 0, this.threatScanOptions());
|
|
2267
2357
|
}
|
|
2268
2358
|
limitFor(target) {
|
|
2269
2359
|
return target === "memory" ? this.memoryLimit : this.userLimit;
|
|
2270
2360
|
}
|
|
2361
|
+
/** P2-1 (v18): the generated date prefix participates in duplicate detection
|
|
2362
|
+
* only when THIS store writes it. With addDatePrefix=false a fact's own
|
|
2363
|
+
* leading `## YYYY-MM-DD\n` is content, not a generated prefix. */
|
|
2364
|
+
dedupeKey(entry) {
|
|
2365
|
+
return this.addDatePrefix ? stripDatePrefix(entry) : entry;
|
|
2366
|
+
}
|
|
2271
2367
|
/**
|
|
2272
2368
|
* Read-guard probe: `{ size, limit }` when the on-disk file exceeds
|
|
2273
2369
|
* `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
|
|
@@ -2452,7 +2548,7 @@ var MemoryStore = class {
|
|
|
2452
2548
|
write: null
|
|
2453
2549
|
};
|
|
2454
2550
|
const entries = [...new Set(normalizeEntries(raw))];
|
|
2455
|
-
if (entries.some((entry) =>
|
|
2551
|
+
if (entries.some((entry) => this.dedupeKey(entry) === content)) {
|
|
2456
2552
|
this.resetFailures();
|
|
2457
2553
|
return {
|
|
2458
2554
|
result: {
|
|
@@ -2574,7 +2670,7 @@ var MemoryStore = class {
|
|
|
2574
2670
|
},
|
|
2575
2671
|
write: null
|
|
2576
2672
|
};
|
|
2577
|
-
if (!working.some((entry) =>
|
|
2673
|
+
if (!working.some((entry) => this.dedupeKey(entry) === body)) working.push(entryBody);
|
|
2578
2674
|
continue;
|
|
2579
2675
|
}
|
|
2580
2676
|
const rawAction = op.action;
|
|
@@ -2777,6 +2873,13 @@ async function recordMutation(root, io, record, cap = 500) {
|
|
|
2777
2873
|
console.warn(`mutation audit record dropped: ${mutationsFile(root)} is malformed and was not overwritten`);
|
|
2778
2874
|
return current;
|
|
2779
2875
|
}
|
|
2876
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
2877
|
+
const version = parsed.version;
|
|
2878
|
+
if (typeof version === "number" && version > 1) {
|
|
2879
|
+
console.warn(`mutation audit record dropped: ${mutationsFile(root)} declares version ${version} (newer than 1); not overwritten`);
|
|
2880
|
+
return current;
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2780
2883
|
const existing = recordsFromParsed(parsed);
|
|
2781
2884
|
existing.push(record);
|
|
2782
2885
|
const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
|
|
@@ -2897,7 +3000,9 @@ function clamp01(value) {
|
|
|
2897
3000
|
return Math.max(0, Math.min(1, value));
|
|
2898
3001
|
}
|
|
2899
3002
|
function daysBetween(from, now) {
|
|
2900
|
-
|
|
3003
|
+
const t = Date.parse(from);
|
|
3004
|
+
if (!Number.isFinite(t)) return 0;
|
|
3005
|
+
return Math.max(0, (now.getTime() - t) / 864e5);
|
|
2901
3006
|
}
|
|
2902
3007
|
function computeQualityScores(input) {
|
|
2903
3008
|
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
@@ -2906,9 +3011,9 @@ function computeQualityScores(input) {
|
|
|
2906
3011
|
const ageDays = Math.max(1, daysBetween(record.created_at, now));
|
|
2907
3012
|
const idleDays = daysBetween(latestActivityAt(record) ?? record.created_at, now);
|
|
2908
3013
|
const patchCount = record.patch_count;
|
|
2909
|
-
const
|
|
2910
|
-
const usageFrequency = clamp01(
|
|
2911
|
-
const stability =
|
|
3014
|
+
const loadCount = record.use_count + record.view_count;
|
|
3015
|
+
const usageFrequency = clamp01(loadCount / ageDays);
|
|
3016
|
+
const stability = loadCount === 0 ? 1 : clamp01(1 - patchCount / loadCount);
|
|
2912
3017
|
const recency = idleDays < 30 ? 1 : clamp01(1 - (idleDays - 30) / 150);
|
|
2913
3018
|
const references = clamp01((input.referenceCounts?.get(name) ?? 0) / 3);
|
|
2914
3019
|
const mutationMaturity = patchCount === 0 ? .3 : patchCount === 1 ? .4 : clamp01((patchCount - 1) / Math.max(1, ageDays / 30));
|
|
@@ -3053,7 +3158,7 @@ const SECRET_PATTERNS = [
|
|
|
3053
3158
|
["jwt", /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
|
|
3054
3159
|
["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
|
|
3055
3160
|
];
|
|
3056
|
-
const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("((
|
|
3161
|
+
const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]{0,64}[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]{0,64})?)\\b([\\t ]*[:=][\\t ]*)([^\\r\\n]+)", "gi");
|
|
3057
3162
|
/**
|
|
3058
3163
|
* Mask credential-shaped text before it crosses a session boundary.
|
|
3059
3164
|
* @param text - the text about to be sent to a model outside this session.
|
|
@@ -3062,7 +3167,7 @@ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("((?:\\b|[\\w-]+[_\
|
|
|
3062
3167
|
function redactSecrets(text) {
|
|
3063
3168
|
let out = text;
|
|
3064
3169
|
for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
|
|
3065
|
-
out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match,
|
|
3170
|
+
out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, lead, prefix, key, separator) => `${lead ?? ""}${prefix ?? ""}${key ?? ""}${separator ?? ""}<redacted>`);
|
|
3066
3171
|
return out;
|
|
3067
3172
|
}
|
|
3068
3173
|
//#endregion
|
|
@@ -3150,6 +3255,22 @@ const CORRECTION_PATTERNS = [
|
|
|
3150
3255
|
/remember\s+(?:this|that|to)/i
|
|
3151
3256
|
];
|
|
3152
3257
|
const FIX_PATTERNS = [/worked after|fixed by|the fix was|root cause/i, /retry(?:ing)? worked|workaround/i];
|
|
3258
|
+
/** Text of one persisted content block, or `''` for any other shape.
|
|
3259
|
+
*
|
|
3260
|
+
* Content blocks cross the durable session-log boundary, so their runtime
|
|
3261
|
+
* shape is `unknown` even where the static event type promises
|
|
3262
|
+
* `{ type, text }`: a persisted `content: [null]` (A2-7, v18) used to throw a
|
|
3263
|
+
* TypeError here and the review catch swallowed the whole turn's remaining
|
|
3264
|
+
* signals. Keeping the guard in one helper also keeps the branches free of
|
|
3265
|
+
* conditions the static type already excludes.
|
|
3266
|
+
* @param block - one element of a persisted message `content` array.
|
|
3267
|
+
* @returns the block's text when it is a text block, otherwise an empty string.
|
|
3268
|
+
*/
|
|
3269
|
+
function textOfBlock(block) {
|
|
3270
|
+
if (block === null || typeof block !== "object") return "";
|
|
3271
|
+
const candidate = block;
|
|
3272
|
+
return candidate.type === "text" && typeof candidate.text === "string" ? candidate.text : "";
|
|
3273
|
+
}
|
|
3153
3274
|
/** Fold one session event into the current turn observation. */
|
|
3154
3275
|
function observeEvent(signal, event) {
|
|
3155
3276
|
const data = event.data;
|
|
@@ -3157,7 +3278,7 @@ function observeEvent(signal, event) {
|
|
|
3157
3278
|
if (event.type === "user/message") {
|
|
3158
3279
|
const content = data.content;
|
|
3159
3280
|
if (!Array.isArray(content)) return;
|
|
3160
|
-
const text = content.map(
|
|
3281
|
+
const text = content.map(textOfBlock).join(" ");
|
|
3161
3282
|
signal.userChars += text.length;
|
|
3162
3283
|
if (CORRECTION_PATTERNS.some((pattern) => pattern.test(text))) signal.memorySignal = true;
|
|
3163
3284
|
if (FIX_PATTERNS.some((pattern) => pattern.test(text))) signal.skillSignal = true;
|
|
@@ -3166,7 +3287,7 @@ function observeEvent(signal, event) {
|
|
|
3166
3287
|
if (event.type === "assistant/message") {
|
|
3167
3288
|
const message = data.message;
|
|
3168
3289
|
if (!message || !Array.isArray(message.content)) return;
|
|
3169
|
-
const text = message.content.map(
|
|
3290
|
+
const text = message.content.map(textOfBlock).join(" ");
|
|
3170
3291
|
signal.assistantChars += text.length;
|
|
3171
3292
|
return;
|
|
3172
3293
|
}
|
|
@@ -3408,8 +3529,12 @@ const MAX_RESTRUCTURE_MOVES = 5;
|
|
|
3408
3529
|
* dots) used to pass here while every later patch/write/remove on it was
|
|
3409
3530
|
* refused as traversal (an orphan file the user could not touch). */
|
|
3410
3531
|
const RESTRUCTURE_TARGET_RE = /^references\/[a-z0-9](?!.*\.\.)[a-z0-9._-]*\.md$/;
|
|
3532
|
+
/** F-20 (v18): the character rule shared by support-file names and snapshot
|
|
3533
|
+
* `extras/` entry names. The two exported names used to carry the same literal
|
|
3534
|
+
* independently; both now derive from this one. */
|
|
3535
|
+
const SUPPORT_ENTRY_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
3411
3536
|
/** Extra file name carried inside a snapshot's `extras/` directory. */
|
|
3412
|
-
const SNAPSHOT_EXTRA_NAME_RE =
|
|
3537
|
+
const SNAPSHOT_EXTRA_NAME_RE = SUPPORT_ENTRY_NAME_RE;
|
|
3413
3538
|
function skillsRoot(env = process.env) {
|
|
3414
3539
|
return join(evolutionRoot(env), "skills");
|
|
3415
3540
|
}
|
|
@@ -3423,6 +3548,29 @@ function skillsRoot(env = process.env) {
|
|
|
3423
3548
|
function resolveSkillsRoot(config = {}) {
|
|
3424
3549
|
return (config.root ?? "").trim() || skillsRoot();
|
|
3425
3550
|
}
|
|
3551
|
+
/** E-7 (v18): every family row reads ONE root key. `root` is canonical;
|
|
3552
|
+
* `skillsRoot` is a deprecated alias honoured only while `root` is empty (so a
|
|
3553
|
+
* deployment that sets both keeps the canonical one) and removed after 0.3.65.
|
|
3554
|
+
* Callers log their own deprecation warning.
|
|
3555
|
+
* @param config - the raw plugin config, carrying `root` and/or `skillsRoot`.
|
|
3556
|
+
* @returns the effective root (empty when neither key is set) and whether the
|
|
3557
|
+
* deprecated alias supplied it.
|
|
3558
|
+
*/
|
|
3559
|
+
function resolveRootConfig(config = {}) {
|
|
3560
|
+
const root = (config.root ?? "").trim();
|
|
3561
|
+
if (root !== "") return {
|
|
3562
|
+
root,
|
|
3563
|
+
usedDeprecatedAlias: false
|
|
3564
|
+
};
|
|
3565
|
+
const alias = (config.skillsRoot ?? "").trim();
|
|
3566
|
+
return alias === "" ? {
|
|
3567
|
+
root: "",
|
|
3568
|
+
usedDeprecatedAlias: false
|
|
3569
|
+
} : {
|
|
3570
|
+
root: alias,
|
|
3571
|
+
usedDeprecatedAlias: true
|
|
3572
|
+
};
|
|
3573
|
+
}
|
|
3426
3574
|
/**
|
|
3427
3575
|
* Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
|
|
3428
3576
|
* the APPROVAL surface treats every delegated subagent as the autonomous
|
|
@@ -3459,6 +3607,15 @@ function skillDir(root, name) {
|
|
|
3459
3607
|
function markerEntryName(marker) {
|
|
3460
3608
|
return `.${marker}`;
|
|
3461
3609
|
}
|
|
3610
|
+
/** F-17 (v18): the root-level lock files a DESTRUCTIVE MOVER must treat as an
|
|
3611
|
+
* active writer (skill body + the two marker writers). Single source with
|
|
3612
|
+
* `markerEntryName`/`LOCK_SUFFIX` so a renamed marker cannot silently drop out
|
|
3613
|
+
* of the ghost-writer probe. */
|
|
3614
|
+
const MARKER_LOCK_NAMES = [
|
|
3615
|
+
`SKILL.md${LOCK_SUFFIX}`,
|
|
3616
|
+
`.pinned${LOCK_SUFFIX}`,
|
|
3617
|
+
`.hermes-managed${LOCK_SUFFIX}`
|
|
3618
|
+
];
|
|
3462
3619
|
function markerPath(dir, marker) {
|
|
3463
3620
|
return join(dir, markerEntryName(marker));
|
|
3464
3621
|
}
|
|
@@ -3697,6 +3854,13 @@ function authoringFeedback(frontmatter) {
|
|
|
3697
3854
|
lines
|
|
3698
3855
|
};
|
|
3699
3856
|
}
|
|
3857
|
+
/** A1-15 (v18) / P2-2 (v19): the io layer marks an error `committed: true` when
|
|
3858
|
+
* the rename landed and only the directory fsync failed. Every single-file
|
|
3859
|
+
* writer must treat that as "written, durability unconfirmed" — never as a
|
|
3860
|
+
* plain failure (which a caller would retry, or a two-phase caller roll back). */
|
|
3861
|
+
function isCommittedOnly(error) {
|
|
3862
|
+
return error?.committed === true;
|
|
3863
|
+
}
|
|
3700
3864
|
async function listNames(root, io) {
|
|
3701
3865
|
const entries = await io.list(root);
|
|
3702
3866
|
const names = [];
|
|
@@ -3711,7 +3875,7 @@ async function listNames(root, io) {
|
|
|
3711
3875
|
* `[a-z0-9._-]`) — drive-colon / odd-character / uppercase names can no
|
|
3712
3876
|
* longer reach the filesystem through writeSupportFile / patch /
|
|
3713
3877
|
* removeSupportFile. */
|
|
3714
|
-
const SUPPORT_FILE_NAME_RE =
|
|
3878
|
+
const SUPPORT_FILE_NAME_RE = SUPPORT_ENTRY_NAME_RE;
|
|
3715
3879
|
/** C-18: win32 reserves these stems with ANY extension (`nul.md` hits the
|
|
3716
3880
|
* NUL device), and they are fully inside the charset above — so the reserved
|
|
3717
3881
|
* set is checked on the first-dot prefix as well; the charset close alone
|
|
@@ -3742,6 +3906,16 @@ const WIN32_RESERVED_DEVICE_NAMES = new Set([
|
|
|
3742
3906
|
"lpt8",
|
|
3743
3907
|
"lpt9"
|
|
3744
3908
|
]);
|
|
3909
|
+
/** A1-6 (v18): the regex alone admits `references/nul.md` (a Windows device
|
|
3910
|
+
* stem), which the support-file layer refuses. Restructure must use the same
|
|
3911
|
+
* rule, or it creates an orphan the later patch/write/remove paths refuse.
|
|
3912
|
+
* Single source shared with the plan validator. */
|
|
3913
|
+
function validateRestructureTarget(filePath) {
|
|
3914
|
+
if (!RESTRUCTURE_TARGET_RE.test(filePath)) return `toFile must be references/<topic>.md (got "${filePath}").`;
|
|
3915
|
+
const stem = filePath.slice(filePath.lastIndexOf("/") + 1).split(".")[0]?.toLowerCase() ?? "";
|
|
3916
|
+
if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `toFile "${filePath}" uses a Windows reserved device name.`;
|
|
3917
|
+
return null;
|
|
3918
|
+
}
|
|
3745
3919
|
function validateSupportPath(filePath) {
|
|
3746
3920
|
const normalized = filePath.replace(/\\/g, "/");
|
|
3747
3921
|
if (normalized.includes("..")) return "Path traversal is not allowed.";
|
|
@@ -3751,6 +3925,7 @@ function validateSupportPath(filePath) {
|
|
|
3751
3925
|
for (const part of parts.slice(1)) {
|
|
3752
3926
|
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
3927
|
if (part.toLowerCase().endsWith(".lock")) return `Unsupported file name "${part}" — the .lock suffix is reserved for the writer-lock protocol.`;
|
|
3928
|
+
if (part.toLowerCase().endsWith(".corrupt") || part.toLowerCase().endsWith(".tmp")) return `Unsupported file name "${part}" — the .corrupt/.tmp suffixes are reserved for the state/IO protocols.`;
|
|
3754
3929
|
const stem = part.split(".")[0]?.toLowerCase() ?? "";
|
|
3755
3930
|
if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `Unsupported file name "${part}" — a Windows reserved device name.`;
|
|
3756
3931
|
}
|
|
@@ -3958,11 +4133,23 @@ var SkillLibrary = class {
|
|
|
3958
4133
|
outcome = o;
|
|
3959
4134
|
return o.write ?? current ?? null;
|
|
3960
4135
|
};
|
|
3961
|
-
|
|
4136
|
+
let durabilityWarning = "";
|
|
4137
|
+
const committedOnly = (error) => error?.committed === true;
|
|
4138
|
+
if (this.transact) try {
|
|
4139
|
+
await this.transact(this.io, path, run);
|
|
4140
|
+
} catch (error) {
|
|
4141
|
+
if (!committedOnly(error)) throw error;
|
|
4142
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
4143
|
+
}
|
|
3962
4144
|
else {
|
|
3963
4145
|
const current = await this.io.readText(path);
|
|
3964
4146
|
const next = await run(current);
|
|
3965
|
-
if (next !== null && next !== current)
|
|
4147
|
+
if (next !== null && next !== current) try {
|
|
4148
|
+
await this.io.writeText(path, next);
|
|
4149
|
+
} catch (error) {
|
|
4150
|
+
if (!committedOnly(error)) throw error;
|
|
4151
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
4152
|
+
}
|
|
3966
4153
|
}
|
|
3967
4154
|
const o = outcome;
|
|
3968
4155
|
if (o === void 0 || typeof o !== "object" || !Object.prototype.hasOwnProperty.call(o, "write")) return {
|
|
@@ -3971,7 +4158,10 @@ var SkillLibrary = class {
|
|
|
3971
4158
|
};
|
|
3972
4159
|
if (o.write !== null && o.audit) await this.audit(o.audit.skillName, o.audit.action, o.audit.before, o.audit.after, o.audit.summary);
|
|
3973
4160
|
if (o.write !== null && o.event) this.notifyMutation(o.event);
|
|
3974
|
-
return o.result
|
|
4161
|
+
return durabilityWarning === "" || !o.result.ok ? o.result : {
|
|
4162
|
+
...o.result,
|
|
4163
|
+
message: `${o.result.message} (warning: the write landed but the directory fsync failed — durability unconfirmed: ${durabilityWarning})`
|
|
4164
|
+
};
|
|
3975
4165
|
}
|
|
3976
4166
|
/** Notify the mutation observer after a successful write; observers must never fail the mutation. */
|
|
3977
4167
|
notifyMutation(event) {
|
|
@@ -3988,8 +4178,7 @@ var SkillLibrary = class {
|
|
|
3988
4178
|
* label (scanContentThreats already embeds it) plus the self-heal hint, so a
|
|
3989
4179
|
* false-positive rewrite direction is actionable instead of a dead end. */
|
|
3990
4180
|
contentThreatBlock(content) {
|
|
3991
|
-
|
|
3992
|
-
return threat === null ? null : threat + THREAT_EXEMPT_HINT;
|
|
4181
|
+
return scanContentThreats(content, void 0, this.threatScanOptions());
|
|
3993
4182
|
}
|
|
3994
4183
|
async list() {
|
|
3995
4184
|
const summaries = [];
|
|
@@ -3998,20 +4187,42 @@ var SkillLibrary = class {
|
|
|
3998
4187
|
const md = await this.io.readText(join(dir, "SKILL.md"));
|
|
3999
4188
|
if (md === null) continue;
|
|
4000
4189
|
const parsed = parseFrontmatter(md);
|
|
4001
|
-
let entries =
|
|
4190
|
+
let entries = null;
|
|
4002
4191
|
try {
|
|
4003
4192
|
entries = await this.io.list(dir);
|
|
4004
|
-
} catch {
|
|
4005
|
-
|
|
4006
|
-
|
|
4193
|
+
} catch {
|
|
4194
|
+
entries = null;
|
|
4195
|
+
}
|
|
4196
|
+
const probeMarker = async (marker) => {
|
|
4197
|
+
if (entries !== null) return entries.includes(markerEntryName(marker));
|
|
4198
|
+
try {
|
|
4199
|
+
return await this.io.exists(join(dir, markerEntryName(marker)));
|
|
4200
|
+
} catch {
|
|
4201
|
+
return null;
|
|
4202
|
+
}
|
|
4203
|
+
};
|
|
4204
|
+
const [bundled, hubInstalled, pinned, hermesManaged] = await Promise.all([
|
|
4205
|
+
probeMarker("bundled"),
|
|
4206
|
+
probeMarker("hub-installed"),
|
|
4207
|
+
probeMarker("pinned"),
|
|
4208
|
+
probeMarker("hermes-managed")
|
|
4209
|
+
]);
|
|
4210
|
+
const protectedBy = bundled === true ? "bundled" : hubInstalled === true ? "hub-installed" : pinned === true ? "pinned" : null;
|
|
4007
4211
|
const parsedDescription = parsed?.frontmatter.description;
|
|
4212
|
+
const parsedWhenToUse = parsed?.frontmatter.whenToUse;
|
|
4008
4213
|
summaries.push({
|
|
4009
4214
|
name,
|
|
4010
4215
|
description: typeof parsedDescription === "string" ? parsedDescription : "",
|
|
4011
4216
|
path: dir,
|
|
4012
4217
|
protectedBy,
|
|
4013
|
-
|
|
4014
|
-
|
|
4218
|
+
protectionUnknown: [
|
|
4219
|
+
bundled,
|
|
4220
|
+
hubInstalled,
|
|
4221
|
+
pinned,
|
|
4222
|
+
hermesManaged
|
|
4223
|
+
].some((value) => value === null),
|
|
4224
|
+
managed: hermesManaged === true,
|
|
4225
|
+
...typeof parsedWhenToUse === "string" && parsedWhenToUse.trim() !== "" ? { whenToUse: parsedWhenToUse } : {}
|
|
4015
4226
|
});
|
|
4016
4227
|
}
|
|
4017
4228
|
return summaries;
|
|
@@ -4193,6 +4404,9 @@ var SkillLibrary = class {
|
|
|
4193
4404
|
* marker write is the only state change; content is untouched.
|
|
4194
4405
|
*/
|
|
4195
4406
|
async setPinned(name, pinned, origin = "foreground") {
|
|
4407
|
+
return await this.serial(() => this.setPinnedCore(name, pinned, origin));
|
|
4408
|
+
}
|
|
4409
|
+
async setPinnedCore(name, pinned, origin) {
|
|
4196
4410
|
const normalized = name.trim();
|
|
4197
4411
|
const bad = this.badName(normalized);
|
|
4198
4412
|
if (bad) return {
|
|
@@ -4203,6 +4417,7 @@ var SkillLibrary = class {
|
|
|
4203
4417
|
ok: false,
|
|
4204
4418
|
message: "Only the foreground (user or the main agent) may pin or unpin skills."
|
|
4205
4419
|
};
|
|
4420
|
+
let durabilityWarning = "";
|
|
4206
4421
|
const dir = this.dirOf(normalized);
|
|
4207
4422
|
const marker = markerPath(dir, "pinned");
|
|
4208
4423
|
const existing = await this.io.exists(marker);
|
|
@@ -4220,12 +4435,17 @@ var SkillLibrary = class {
|
|
|
4220
4435
|
ok: false,
|
|
4221
4436
|
message: `Skill "${normalized}" not found.`
|
|
4222
4437
|
};
|
|
4223
|
-
if (pinned)
|
|
4438
|
+
if (pinned) try {
|
|
4439
|
+
await this.io.writeText(marker, "");
|
|
4440
|
+
} catch (error) {
|
|
4441
|
+
if (!isCommittedOnly(error)) throw error;
|
|
4442
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
4443
|
+
}
|
|
4224
4444
|
else await this.io.remove(marker);
|
|
4225
4445
|
await this.audit(normalized, pinned ? "pin" : "unpin", null, null, pinned ? "pinned" : "unpinned");
|
|
4226
4446
|
return {
|
|
4227
4447
|
ok: true,
|
|
4228
|
-
message: pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned
|
|
4448
|
+
message: `${pinned ? `Skill "${normalized}" pinned: protected from deletion, background review, and the lifecycle.` : `Skill "${normalized}" unpinned.`}${durabilityWarning === "" ? "" : ` (warning: the write landed but the directory fsync failed — durability unconfirmed: ${durabilityWarning})`}`,
|
|
4229
4449
|
path: dir
|
|
4230
4450
|
};
|
|
4231
4451
|
}
|
|
@@ -4275,15 +4495,37 @@ var SkillLibrary = class {
|
|
|
4275
4495
|
const onDisk = finalContent.trimEnd() + "\n";
|
|
4276
4496
|
const createPath = join(dir, "SKILL.md");
|
|
4277
4497
|
let existsAtCommit = false;
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4498
|
+
let taskRan = false;
|
|
4499
|
+
let createDurabilityWarning = "";
|
|
4500
|
+
if (this.transact) try {
|
|
4501
|
+
await this.transact(this.io, createPath, (current) => {
|
|
4502
|
+
taskRan = true;
|
|
4503
|
+
if (current !== null) {
|
|
4504
|
+
existsAtCommit = true;
|
|
4505
|
+
return current;
|
|
4506
|
+
}
|
|
4507
|
+
return onDisk;
|
|
4508
|
+
});
|
|
4509
|
+
} catch (error) {
|
|
4510
|
+
if (!isCommittedOnly(error)) throw error;
|
|
4511
|
+
createDurabilityWarning = error instanceof Error ? error.message : String(error);
|
|
4512
|
+
}
|
|
4513
|
+
else if (await this.io.exists(createPath)) {
|
|
4514
|
+
taskRan = true;
|
|
4515
|
+
existsAtCommit = true;
|
|
4516
|
+
} else {
|
|
4517
|
+
taskRan = true;
|
|
4518
|
+
try {
|
|
4519
|
+
await this.io.writeText(createPath, onDisk);
|
|
4520
|
+
} catch (error) {
|
|
4521
|
+
if (!isCommittedOnly(error)) throw error;
|
|
4522
|
+
createDurabilityWarning = error instanceof Error ? error.message : String(error);
|
|
4282
4523
|
}
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4524
|
+
}
|
|
4525
|
+
if (!taskRan) return {
|
|
4526
|
+
ok: false,
|
|
4527
|
+
message: "internal error: the create transaction did not invoke the task; no file was written"
|
|
4528
|
+
};
|
|
4287
4529
|
if (existsAtCommit) return {
|
|
4288
4530
|
ok: false,
|
|
4289
4531
|
message: `Skill "${normalized}" already exists.`
|
|
@@ -4297,7 +4539,7 @@ var SkillLibrary = class {
|
|
|
4297
4539
|
});
|
|
4298
4540
|
return {
|
|
4299
4541
|
ok: true,
|
|
4300
|
-
message: `Skill "${normalized}" created
|
|
4542
|
+
message: `Skill "${normalized}" created.${createDurabilityWarning === "" ? "" : ` (warning: the write landed but the directory fsync failed — durability unconfirmed: ${createDurabilityWarning})`}`,
|
|
4301
4543
|
path: dir,
|
|
4302
4544
|
...norm.changed ? { normalizedFrontmatterFields: norm.fields } : {}
|
|
4303
4545
|
};
|
|
@@ -4583,11 +4825,7 @@ var SkillLibrary = class {
|
|
|
4583
4825
|
* CRASHED writer also refuses — correct: inspect, don't archive.
|
|
4584
4826
|
*/
|
|
4585
4827
|
async hasWriteLock(dir) {
|
|
4586
|
-
const markerLocks =
|
|
4587
|
-
join(dir, "SKILL.md.lock"),
|
|
4588
|
-
join(dir, ".pinned.lock"),
|
|
4589
|
-
join(dir, ".hermes-managed.lock")
|
|
4590
|
-
];
|
|
4828
|
+
const markerLocks = MARKER_LOCK_NAMES.map((name) => join(dir, name));
|
|
4591
4829
|
for (const lock of markerLocks) if (await this.isWriterLock(lock)) return true;
|
|
4592
4830
|
for (const supportDir of SUPPORT_DIRS) {
|
|
4593
4831
|
let entries;
|
|
@@ -4609,17 +4847,25 @@ var SkillLibrary = class {
|
|
|
4609
4847
|
* or be swept as residue — the v16 first cut matched on suffix alone,
|
|
4610
4848
|
* which permanently refused archiving and deleted user content on restore. */
|
|
4611
4849
|
async isWriterLock(lockPath) {
|
|
4612
|
-
|
|
4850
|
+
let body;
|
|
4851
|
+
try {
|
|
4852
|
+
body = await this.io.readText(lockPath);
|
|
4853
|
+
} catch {
|
|
4854
|
+
return true;
|
|
4855
|
+
}
|
|
4613
4856
|
if (body === null) return false;
|
|
4614
|
-
return
|
|
4857
|
+
return LOCK_BODY_RE.test(body.trim());
|
|
4615
4858
|
}
|
|
4616
|
-
/** P2 (v16): best-effort removal of lock residue inside a RESTORED tree
|
|
4617
|
-
*
|
|
4618
|
-
*
|
|
4619
|
-
*
|
|
4620
|
-
*
|
|
4859
|
+
/** P2 (v16): best-effort removal of lock residue inside a RESTORED tree.
|
|
4860
|
+
* A1-2/A1-7 (v18): the sweep now covers the marker locks the probe checks
|
|
4861
|
+
* (`SKILL.md.lock`/`.pinned.lock`/`.hermes-managed.lock`) and only removes
|
|
4862
|
+
* a lock whose holder pid is NOT alive — a live writer's lock is never
|
|
4863
|
+
* stolen by the sweep. A dead-pid residue would otherwise permanently
|
|
4864
|
+
* refuse archive/restore. */
|
|
4621
4865
|
async deleteStrandedLocks(dir) {
|
|
4622
4866
|
await this.sweepLockIfStranded(join(dir, "SKILL.md.lock"));
|
|
4867
|
+
await this.sweepLockIfStranded(join(dir, ".pinned.lock"));
|
|
4868
|
+
await this.sweepLockIfStranded(join(dir, ".hermes-managed.lock"));
|
|
4623
4869
|
for (const supportDir of SUPPORT_DIRS) {
|
|
4624
4870
|
let entries = [];
|
|
4625
4871
|
try {
|
|
@@ -4631,10 +4877,41 @@ var SkillLibrary = class {
|
|
|
4631
4877
|
}
|
|
4632
4878
|
}
|
|
4633
4879
|
/** Remove `lockPath` only when its body has the writer-lock `pid:token`
|
|
4634
|
-
* shape; anything else (a user support file
|
|
4880
|
+
* shape AND the holder pid is not alive; anything else (a user support file
|
|
4881
|
+
* or a live writer's lock) is left untouched. */
|
|
4635
4882
|
async sweepLockIfStranded(lockPath) {
|
|
4636
4883
|
const body = await this.io.readText(lockPath).catch(() => null);
|
|
4637
|
-
if (body === null
|
|
4884
|
+
if (body === null) return;
|
|
4885
|
+
const match = /^(\d+):[0-9a-f]*$/.exec(body.trim());
|
|
4886
|
+
if (match === null) return;
|
|
4887
|
+
const pid = Number(match[1]);
|
|
4888
|
+
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return;
|
|
4889
|
+
await this.io.remove(lockPath).catch(() => {});
|
|
4890
|
+
}
|
|
4891
|
+
/** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
|
|
4892
|
+
* only a single, non-traversing path component is safe. Dotfiles
|
|
4893
|
+
* (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed.
|
|
4894
|
+
* P2-4 (v19): a non-string entry (`skills: [123]`) is refused structurally
|
|
4895
|
+
* instead of throwing `name.includes is not a function`. */
|
|
4896
|
+
safeSnapshotEntryName(name) {
|
|
4897
|
+
if (typeof name !== "string") return false;
|
|
4898
|
+
return name !== "" && name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\") && basename(name) === name;
|
|
4899
|
+
}
|
|
4900
|
+
/** A1-7 (v18): a root-level lock whose holder is alive must refuse the
|
|
4901
|
+
* restore; a dead residue is swept so a crashed writer cannot block
|
|
4902
|
+
* recovery. A non-lock body shape is left alone (user file). */
|
|
4903
|
+
async refuseLiveLockOrSweep(lockPath, label) {
|
|
4904
|
+
let body;
|
|
4905
|
+
try {
|
|
4906
|
+
body = await this.io.readText(lockPath);
|
|
4907
|
+
} catch (error) {
|
|
4908
|
+
throw new Error(`snapshot restore refused: cannot verify ${label} (${error instanceof Error ? error.message : String(error)}); a live writer may hold it`);
|
|
4909
|
+
}
|
|
4910
|
+
if (body === null) return;
|
|
4911
|
+
const match = /^(\d+):[0-9a-f]*$/.exec(body.trim());
|
|
4912
|
+
if (match === null) return;
|
|
4913
|
+
const pid = Number(match[1]);
|
|
4914
|
+
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) throw new Error(`snapshot restore refused: ${label} is being written (write lock present); retry once the write completes`);
|
|
4638
4915
|
await this.io.remove(lockPath).catch(() => {});
|
|
4639
4916
|
}
|
|
4640
4917
|
async archive(rawName, options = {}) {
|
|
@@ -4921,9 +5198,10 @@ var SkillLibrary = class {
|
|
|
4921
5198
|
ok: false,
|
|
4922
5199
|
message: "Every restructure move needs a non-empty heading."
|
|
4923
5200
|
};
|
|
4924
|
-
|
|
5201
|
+
const targetIssue = validateRestructureTarget(move.toFile);
|
|
5202
|
+
if (targetIssue) return {
|
|
4925
5203
|
ok: false,
|
|
4926
|
-
message:
|
|
5204
|
+
message: targetIssue
|
|
4927
5205
|
};
|
|
4928
5206
|
}
|
|
4929
5207
|
const dir = this.dirOf(name);
|
|
@@ -5020,16 +5298,17 @@ var SkillLibrary = class {
|
|
|
5020
5298
|
ok: false,
|
|
5021
5299
|
message: `Skill "${name}" is protected (${protection}).`
|
|
5022
5300
|
};
|
|
5023
|
-
for (const precondition of plan.preconditions ?? []) {
|
|
5024
|
-
const issue = await precondition({ dir });
|
|
5025
|
-
if (issue) return {
|
|
5026
|
-
ok: false,
|
|
5027
|
-
message: issue
|
|
5028
|
-
};
|
|
5029
|
-
}
|
|
5030
5301
|
const landing = [];
|
|
5031
5302
|
for (const write of plan.writes) {
|
|
5032
|
-
|
|
5303
|
+
let previous;
|
|
5304
|
+
try {
|
|
5305
|
+
previous = await this.io.readText(write.target);
|
|
5306
|
+
} catch (error) {
|
|
5307
|
+
return {
|
|
5308
|
+
ok: false,
|
|
5309
|
+
message: `Tree change refused: cannot safely pre-read ${write.target} (${error instanceof Error ? error.message : String(error)}); no writes were performed`
|
|
5310
|
+
};
|
|
5311
|
+
}
|
|
5033
5312
|
if (Buffer.byteLength(write.content, "utf8") > this.limits.maxSkillFileBytes) return {
|
|
5034
5313
|
ok: false,
|
|
5035
5314
|
message: `Write exceeds ${this.limits.maxSkillFileBytes} bytes: ${write.target}`
|
|
@@ -5045,18 +5324,16 @@ var SkillLibrary = class {
|
|
|
5045
5324
|
previous
|
|
5046
5325
|
});
|
|
5047
5326
|
}
|
|
5048
|
-
const semantic = plan.validate?.({
|
|
5049
|
-
dir,
|
|
5050
|
-
currentMd: md
|
|
5051
|
-
}) ?? null;
|
|
5052
|
-
if (semantic) return {
|
|
5053
|
-
ok: false,
|
|
5054
|
-
message: semantic
|
|
5055
|
-
};
|
|
5056
5327
|
const written = [];
|
|
5328
|
+
let durabilityWarning = "";
|
|
5057
5329
|
try {
|
|
5058
5330
|
for (const entry of landing) {
|
|
5059
|
-
|
|
5331
|
+
try {
|
|
5332
|
+
await this.io.writeText(entry.target, entry.content);
|
|
5333
|
+
} catch (error) {
|
|
5334
|
+
if (error?.committed !== true) throw error;
|
|
5335
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
5336
|
+
}
|
|
5060
5337
|
written.push({
|
|
5061
5338
|
target: entry.target,
|
|
5062
5339
|
previous: entry.previous
|
|
@@ -5077,7 +5354,7 @@ var SkillLibrary = class {
|
|
|
5077
5354
|
});
|
|
5078
5355
|
return {
|
|
5079
5356
|
ok: true,
|
|
5080
|
-
message: `${plan.eventAction} "${name}" succeeded
|
|
5357
|
+
message: durabilityWarning === "" ? `${plan.eventAction} "${name}" succeeded.` : `${plan.eventAction} "${name}" succeeded (warning: the write landed but the directory fsync failed — durability unconfirmed: ${durabilityWarning})`,
|
|
5081
5358
|
path: dir
|
|
5082
5359
|
};
|
|
5083
5360
|
}
|
|
@@ -5093,9 +5370,10 @@ var SkillLibrary = class {
|
|
|
5093
5370
|
ok: false,
|
|
5094
5371
|
message: bad
|
|
5095
5372
|
};
|
|
5096
|
-
|
|
5373
|
+
const dest = this.dirOf(name);
|
|
5374
|
+
if (await this.io.exists(dest)) return {
|
|
5097
5375
|
ok: false,
|
|
5098
|
-
message: `Skill "${name}" already exists in the active root; refusing to overwrite.`
|
|
5376
|
+
message: await this.io.exists(join(dest, "SKILL.md")) ? `Skill "${name}" already exists in the active root; refusing to overwrite.` : `Skill directory "${name}" already exists in the active root but carries no SKILL.md; remove or repair it before restoring.`
|
|
5099
5377
|
};
|
|
5100
5378
|
const archiveRoot = join(this.root, ".archive");
|
|
5101
5379
|
let entries;
|
|
@@ -5118,7 +5396,6 @@ var SkillLibrary = class {
|
|
|
5118
5396
|
message: `Skill "${name}" is not in .archive.`
|
|
5119
5397
|
};
|
|
5120
5398
|
const source = join(archiveRoot, chosen);
|
|
5121
|
-
const dest = this.dirOf(name);
|
|
5122
5399
|
if (this.io.isSymlink) {
|
|
5123
5400
|
if (await this.io.isSymlink(source) === true) return {
|
|
5124
5401
|
ok: false,
|
|
@@ -5251,7 +5528,8 @@ var SkillLibrary = class {
|
|
|
5251
5528
|
ok: false,
|
|
5252
5529
|
message: `"${filePath}" is not a readable regular file — remove the files inside it one by one.`
|
|
5253
5530
|
};
|
|
5254
|
-
await this.io
|
|
5531
|
+
if (this.transact) await this.transact(this.io, target, () => null);
|
|
5532
|
+
else await this.io.remove(target);
|
|
5255
5533
|
await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
|
|
5256
5534
|
this.notifyMutation({
|
|
5257
5535
|
action: "remove_file",
|
|
@@ -5278,9 +5556,10 @@ var SkillLibrary = class {
|
|
|
5278
5556
|
while (await this.io.exists(dest)) dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
|
|
5279
5557
|
try {
|
|
5280
5558
|
const names = await listNames(this.root, this.io);
|
|
5281
|
-
await Promise.
|
|
5559
|
+
const copyFailure = (await Promise.allSettled(names.map(async (name) => {
|
|
5282
5560
|
await this.io.copy(this.dirOf(name), join(dest, name));
|
|
5283
|
-
}));
|
|
5561
|
+
}))).find((result) => result.status === "rejected");
|
|
5562
|
+
if (copyFailure) throw copyFailure.reason;
|
|
5284
5563
|
const sidecars = [];
|
|
5285
5564
|
for (const sidecar of [usageFile(this.root), suppressedFile(this.root)]) if (await this.io.exists(sidecar)) {
|
|
5286
5565
|
const name = basename(sidecar);
|
|
@@ -5295,9 +5574,10 @@ var SkillLibrary = class {
|
|
|
5295
5574
|
}
|
|
5296
5575
|
const validExtras = extras.filter((extra) => SNAPSHOT_EXTRA_NAME_RE.test(extra.name));
|
|
5297
5576
|
const extraNames = validExtras.map((extra) => extra.name);
|
|
5298
|
-
await Promise.
|
|
5577
|
+
const extraFailure = (await Promise.allSettled(validExtras.map(async (extra) => {
|
|
5299
5578
|
await this.io.writeText(join(dest, "extras", extra.name), extra.content);
|
|
5300
|
-
}));
|
|
5579
|
+
}))).find((result) => result.status === "rejected");
|
|
5580
|
+
if (extraFailure) throw extraFailure.reason;
|
|
5301
5581
|
await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
|
|
5302
5582
|
reason,
|
|
5303
5583
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -5319,10 +5599,11 @@ var SkillLibrary = class {
|
|
|
5319
5599
|
if (raw === null) return null;
|
|
5320
5600
|
try {
|
|
5321
5601
|
const manifest = JSON.parse(raw);
|
|
5602
|
+
if (!Array.isArray(manifest.skills)) return null;
|
|
5322
5603
|
return {
|
|
5323
5604
|
reason: typeof manifest.reason === "string" ? manifest.reason : "",
|
|
5324
5605
|
createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "",
|
|
5325
|
-
skills:
|
|
5606
|
+
skills: manifest.skills,
|
|
5326
5607
|
sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
|
|
5327
5608
|
...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
|
|
5328
5609
|
extras: Array.isArray(manifest.extras) ? manifest.extras : []
|
|
@@ -5357,6 +5638,7 @@ var SkillLibrary = class {
|
|
|
5357
5638
|
reason: manifest.reason
|
|
5358
5639
|
});
|
|
5359
5640
|
}
|
|
5641
|
+
out.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || "") || b.path.localeCompare(a.path));
|
|
5360
5642
|
return out;
|
|
5361
5643
|
}
|
|
5362
5644
|
/**
|
|
@@ -5427,6 +5709,21 @@ var SkillLibrary = class {
|
|
|
5427
5709
|
* restoreLatestSnapshot so a failed restore can roll itself back (E-13).
|
|
5428
5710
|
*/
|
|
5429
5711
|
async restoreSnapshotIntoRoot(snapshotPath) {
|
|
5712
|
+
const manifest = await this.readSnapshotManifest(snapshotPath);
|
|
5713
|
+
if (manifest === null) throw new Error(await this.io.exists(join(snapshotPath, "manifest.json")) ? `snapshot ${snapshotPath} has an unreadable manifest.json; refusing to clear the active tree` : `snapshot ${snapshotPath} has no readable manifest.json; refusing to restore`);
|
|
5714
|
+
for (const name of [...manifest.skills, ...manifest.sidecars]) if (!this.safeSnapshotEntryName(name)) throw new Error(`snapshot ${snapshotPath} declares an unsafe entry name ${JSON.stringify(name)}; refusing to restore`);
|
|
5715
|
+
if (manifest.skills.length === 0) {
|
|
5716
|
+
const snapshotEntries = await this.io.list(snapshotPath);
|
|
5717
|
+
const declared = new Set([
|
|
5718
|
+
"manifest.json",
|
|
5719
|
+
"extras",
|
|
5720
|
+
".archive",
|
|
5721
|
+
...manifest.skills,
|
|
5722
|
+
...manifest.sidecars
|
|
5723
|
+
]);
|
|
5724
|
+
const undeclared = snapshotEntries.filter((entry) => !declared.has(entry));
|
|
5725
|
+
if (undeclared.length > 0) throw new Error(`snapshot ${snapshotPath} declares no skills but contains undeclared entries (${undeclared.join(", ")}); refusing to clear the active tree`);
|
|
5726
|
+
}
|
|
5430
5727
|
let rootEntries;
|
|
5431
5728
|
try {
|
|
5432
5729
|
rootEntries = await this.io.list(this.root);
|
|
@@ -5435,16 +5732,26 @@ var SkillLibrary = class {
|
|
|
5435
5732
|
}
|
|
5436
5733
|
for (const entry of rootEntries) {
|
|
5437
5734
|
if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json" || entry === ".curator-suppressed.json") continue;
|
|
5438
|
-
|
|
5735
|
+
if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) {
|
|
5736
|
+
await this.refuseLiveLockOrSweep(join(this.root, entry), entry);
|
|
5737
|
+
continue;
|
|
5738
|
+
}
|
|
5739
|
+
const dir = join(this.root, entry);
|
|
5740
|
+
if (await this.io.exists(join(dir, "SKILL.md"))) {
|
|
5741
|
+
await this.deleteStrandedLocks(dir);
|
|
5742
|
+
if (await this.hasWriteLock(dir)) throw new Error(`snapshot restore refused: skill "${entry}" is being written (write lock present); retry once the write completes`);
|
|
5743
|
+
}
|
|
5439
5744
|
}
|
|
5440
|
-
const
|
|
5441
|
-
|
|
5442
|
-
if (entry === "
|
|
5443
|
-
|
|
5745
|
+
const restoresSuppressed = manifest.sidecars.includes(".curator-suppressed.json");
|
|
5746
|
+
for (const entry of rootEntries) {
|
|
5747
|
+
if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json") continue;
|
|
5748
|
+
if (entry === ".curator-suppressed.json" && restoresSuppressed) continue;
|
|
5749
|
+
if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) continue;
|
|
5750
|
+
await this.io.remove(join(this.root, entry));
|
|
5444
5751
|
}
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5752
|
+
for (const name of manifest.skills) await this.io.copy(join(snapshotPath, name), join(this.root, name));
|
|
5753
|
+
for (const sidecar of manifest.sidecars) await this.io.copy(join(snapshotPath, sidecar), join(this.root, sidecar));
|
|
5754
|
+
{
|
|
5448
5755
|
const archiveRoot = join(this.root, ".archive");
|
|
5449
5756
|
if (manifest.hasArchive === true) {
|
|
5450
5757
|
await this.io.remove(archiveRoot);
|
|
@@ -5458,4 +5765,4 @@ var SkillLibrary = class {
|
|
|
5458
5765
|
}
|
|
5459
5766
|
};
|
|
5460
5767
|
//#endregion
|
|
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,
|
|
5768
|
+
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, LOCK_BODY_RE, LOCK_SUFFIX, 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, 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, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterYamlUnsafeValues, getRecord, isProcessAlive, 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, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, skillsRoot, suppressedFile, transactIo, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
|