@lmzhen/dsh-evolution-core 0.3.63 → 0.3.64
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 +378 -126
- 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 +45 -14
- package/lib/types/usage.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,16 +29,17 @@ Single-file writes (`update`, `patch`, `writeSupportFile`)
|
|
|
29
29
|
additionally run the read and the write inside `transactIo` when a caller
|
|
30
30
|
injects a `transact` into the constructor — that is the cross-process lock, so
|
|
31
31
|
two processes sharing `DSH_HOME` cannot interleave their RMW on one file.
|
|
32
|
-
`create`
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
`create` is INSIDE the serial chain and, when a transact backend is bound, its
|
|
33
|
+
exists check runs inside the same per-file transact (v18); `archive`/`consolidate`
|
|
34
|
+
are rename-based two-phase paths and stay outside the single-file serial chain.
|
|
35
|
+
|
|
36
|
+
**v18 residual (updated):** `create`, `removeSupportFile` and `setPinned` now
|
|
37
|
+
run on the serial chain (`removeSupportFile`'s delete also goes through the
|
|
38
|
+
per-file transact), so the remaining single-file residual is the protection
|
|
39
|
+
TOCTOU (a marker check outside the transact) and the multi-file two-phase
|
|
40
|
+
paths `archive`/`consolidate`/`restructure`. The race needs a concurrent
|
|
41
|
+
mutator on the SAME skill file/package; the exposure is acknowledged and the
|
|
42
|
+
protection check is the next candidate (see the v18 optimization plan, E-5).
|
|
42
43
|
|
|
43
44
|
When the backend provides `transact` (nodeEvolutionIo and the io adapter do),
|
|
44
45
|
the constructor binds it BY DEFAULT since 0.3.27 — the single-file entry points
|
|
@@ -47,8 +48,9 @@ the constructor binds it BY DEFAULT since 0.3.27 — the single-file entry point
|
|
|
47
48
|
instantiation, so same-file concurrent writes from different processes no
|
|
48
49
|
longer resolve to last-writer-wins there. An explicit `transact` argument
|
|
49
50
|
overrides the default binding. The two-phase paths deliberately stay outside
|
|
50
|
-
that lock: `create`
|
|
51
|
-
|
|
51
|
+
that lock: `create`'s exists probe runs inside the transact when a transact
|
|
52
|
+
backend is bound (v18), so only a transact-less custom backend can still
|
|
53
|
+
double-pass the probe across processes; `archive`/`consolidate` are rename-based with best-effort
|
|
52
54
|
rollback (an archive loser's rollback surfaces the raw failure when the source
|
|
53
55
|
vanished), and `restructure`'s multi-file swap can expose an interleaved tree
|
|
54
56
|
to a concurrent reader. These residual windows are documented rather than
|
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 (name.includes(".corrupt")) {
|
|
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 {
|
|
@@ -566,6 +589,11 @@ async function mutateUsage(root, io, task) {
|
|
|
566
589
|
if (current !== null) try {
|
|
567
590
|
const probe = JSON.parse(current);
|
|
568
591
|
if (probe === null || Array.isArray(probe) || typeof probe !== "object") shapePreserved = true;
|
|
592
|
+
else {
|
|
593
|
+
const record = probe;
|
|
594
|
+
if (Object.values(record).some((value) => value === null || typeof value !== "object" || Array.isArray(value))) shapePreserved = true;
|
|
595
|
+
if (typeof record.version === "number" && record.version > 1) shapePreserved = true;
|
|
596
|
+
}
|
|
569
597
|
} catch {
|
|
570
598
|
return current;
|
|
571
599
|
}
|
|
@@ -617,7 +645,8 @@ function applyCuratorMetaFields(disk, curated) {
|
|
|
617
645
|
* transitioned — a concurrent curator run's archive/restore is never reverted
|
|
618
646
|
* by a stale snapshot; without it both pairs apply everywhere.
|
|
619
647
|
*/
|
|
620
|
-
function foldCuratorFields(disk, curated, stateOwned) {
|
|
648
|
+
function foldCuratorFields(disk, curated, stateOwned, runStartStates) {
|
|
649
|
+
const skipped = [];
|
|
621
650
|
for (const [name, record] of curated) {
|
|
622
651
|
const diskRecord = disk.get(name);
|
|
623
652
|
if (!diskRecord) {
|
|
@@ -625,8 +654,16 @@ function foldCuratorFields(disk, curated, stateOwned) {
|
|
|
625
654
|
continue;
|
|
626
655
|
}
|
|
627
656
|
applyCuratorMetaFields(diskRecord, record);
|
|
628
|
-
if (stateOwned === void 0 || stateOwned.has(name))
|
|
657
|
+
if (stateOwned === void 0 || stateOwned.has(name)) {
|
|
658
|
+
const expected = runStartStates?.get(name);
|
|
659
|
+
if (expected !== void 0 && diskRecord.state !== expected) {
|
|
660
|
+
skipped.push(name);
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
applyCuratorLifecycleFields(diskRecord, record);
|
|
664
|
+
}
|
|
629
665
|
}
|
|
666
|
+
return skipped;
|
|
630
667
|
}
|
|
631
668
|
/** Whole-file usage write (V6-37, 0.3.37): this is the ONE path that bypasses
|
|
632
669
|
* the malformed-defense and the transact lock — prefer `mutateUsage` for any
|
|
@@ -757,8 +794,14 @@ async function updateSuppressedNames(root, io, task) {
|
|
|
757
794
|
* threshold, which are intentionally left where they are used.
|
|
758
795
|
* @module @lmzhen/dsh-evolution-core
|
|
759
796
|
*/
|
|
760
|
-
/** Skill frontmatter `name` validated for the file name (lowercase + hyphen).
|
|
761
|
-
|
|
797
|
+
/** Skill frontmatter `name` validated for the file name (lowercase + hyphen).
|
|
798
|
+
* 计划 B-4 (v18): tightened to the UPSTREAM `SKILL_NAME` shape
|
|
799
|
+
* (`/^[a-z0-9]+(?:-[a-z0-9]+)*$/`, packages/skill/skill/src/index.ts:20). The
|
|
800
|
+
* old form admitted trailing/consecutive hyphens, which upstream
|
|
801
|
+
* `validateCandidate` throws on — and that throw aborts the WHOLE `ctx.skills`
|
|
802
|
+
* collection. The catalog provider still filters such legacy tree entries so
|
|
803
|
+
* an existing tree cannot break a session. */
|
|
804
|
+
const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
762
805
|
/** Allowed skill support-file subdirectories (path-traversal boundary). */
|
|
763
806
|
const SUPPORT_DIRS = [
|
|
764
807
|
"references",
|
|
@@ -796,7 +839,10 @@ const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
|
|
|
796
839
|
const DEFAULT_USER_CHAR_LIMIT = 1375;
|
|
797
840
|
/** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
|
|
798
841
|
const DEFAULT_CONSOLIDATION_FAILURES = 3;
|
|
799
|
-
|
|
842
|
+
/** F-20 (v18): the authored-body budget and the hard ceiling are the same
|
|
843
|
+
* number today. Derive it so a future divergence is one edit, not two names
|
|
844
|
+
* that silently disagree. */
|
|
845
|
+
const DEFAULT_SKILL_CONTENT_CHARS = MAX_SKILL_CONTENT_CHARS;
|
|
800
846
|
/** P3-19 (v14): defaults that were written twice (schema `.default()` AND the
|
|
801
847
|
* clamp fallback literal) now have one home per value. */
|
|
802
848
|
const DEFAULT_REVIEW_TIMEOUT_MS = 12e4;
|
|
@@ -1040,7 +1086,10 @@ function computeScopeView(usage, config, protectedNames, gates) {
|
|
|
1040
1086
|
};
|
|
1041
1087
|
}
|
|
1042
1088
|
function daysSince(iso, created, now) {
|
|
1043
|
-
|
|
1089
|
+
const anchor = iso ?? created;
|
|
1090
|
+
const t = Date.parse(anchor);
|
|
1091
|
+
if (!Number.isFinite(t)) return 0;
|
|
1092
|
+
return (now - t) / 864e5;
|
|
1044
1093
|
}
|
|
1045
1094
|
function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Date(), gates, protectedNames) {
|
|
1046
1095
|
const result = {
|
|
@@ -1053,9 +1102,9 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
1053
1102
|
for (const [name, record] of usage) {
|
|
1054
1103
|
if (!lifecycleCandidate(name, record, config, config.bundledNames?.has(name) === true, gateSet, protectedNames)) continue;
|
|
1055
1104
|
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
1105
|
const qualityWarn = record.quality_warn === true || record.feedback_warn === true;
|
|
1106
|
+
if (record.use_count + record.view_count === 0 && !qualityWarn && age < config.staleAfterDays) continue;
|
|
1107
|
+
const idle = daysSince(latestActivityAt(record), record.created_at, now.getTime());
|
|
1059
1108
|
const staleAfterDays = qualityWarn && config.qualityWarnStaleAfterDays !== void 0 ? config.qualityWarnStaleAfterDays : config.staleAfterDays;
|
|
1060
1109
|
if (record.state === "active") {
|
|
1061
1110
|
if (idle >= config.archiveAfterDays) {
|
|
@@ -1148,6 +1197,28 @@ const EVENT_ARCHIVE_RE = /^events-(\d+)\.json$/;
|
|
|
1148
1197
|
function eventsFile(home) {
|
|
1149
1198
|
return join(home, "evolution", "events.json");
|
|
1150
1199
|
}
|
|
1200
|
+
/** I-5 (v18): one lightweight description of the durable event payload
|
|
1201
|
+
* contract. The log is a FILE boundary (a host, a script or an older version
|
|
1202
|
+
* can write it), so `appendEvolutionEvent` refuses a record no consumer can
|
|
1203
|
+
* fold instead of persisting it and failing silently later. The process event
|
|
1204
|
+
* bus stays unvalidated — that is a typed same-process boundary.
|
|
1205
|
+
* @param event - the candidate event record.
|
|
1206
|
+
* @returns a human-readable issue, or null when the record is well-formed.
|
|
1207
|
+
*/
|
|
1208
|
+
function evolutionEventPayloadIssue(event) {
|
|
1209
|
+
const type = event.type;
|
|
1210
|
+
if (typeof type !== "string") return `unknown event type "${String(type)}"`;
|
|
1211
|
+
switch (type) {
|
|
1212
|
+
case "feedback":
|
|
1213
|
+
if (event.kind !== "skill" && event.kind !== "session") return "feedback event requires kind skill|session";
|
|
1214
|
+
if (event.rating !== "positive" && event.rating !== "negative") return "feedback event requires rating positive|negative";
|
|
1215
|
+
return null;
|
|
1216
|
+
case "maintain": return typeof event.runId === "string" ? null : "maintain event requires runId";
|
|
1217
|
+
case "learn":
|
|
1218
|
+
case "usage": return null;
|
|
1219
|
+
default: return `unknown event type "${type}"`;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1151
1222
|
function isEventRecord(event) {
|
|
1152
1223
|
const seq = event?.seq;
|
|
1153
1224
|
return typeof event === "object" && event !== null && typeof seq === "number" && Number.isFinite(seq);
|
|
@@ -1220,6 +1291,8 @@ async function listEventArchives(io, path) {
|
|
|
1220
1291
|
* event can never shadow an archived one in the seq-deduped timeline.
|
|
1221
1292
|
*/
|
|
1222
1293
|
async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
|
|
1294
|
+
const issue = evolutionEventPayloadIssue(event);
|
|
1295
|
+
if (issue !== null) throw new Error(`evolution event refused: ${issue}`);
|
|
1223
1296
|
let assigned = 0;
|
|
1224
1297
|
let refuseMessage = "";
|
|
1225
1298
|
let parsedBody = null;
|
|
@@ -1265,7 +1338,7 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
|
|
|
1265
1338
|
* one-event rotate would archive everything and restart seqs at 1).
|
|
1266
1339
|
*/
|
|
1267
1340
|
async function rotateIfDue(io, path, events, rotateAt) {
|
|
1268
|
-
if (rotateAt < 2 || events.length < rotateAt) return events;
|
|
1341
|
+
if (!Number.isFinite(rotateAt) || rotateAt < 2 || events.length < rotateAt) return events;
|
|
1269
1342
|
const mid = Math.ceil(events.length / 2);
|
|
1270
1343
|
const head = events.slice(0, mid);
|
|
1271
1344
|
const tail = events.slice(mid);
|
|
@@ -1919,7 +1992,7 @@ const PATTERNS = [
|
|
|
1919
1992
|
label: "disregard_rules",
|
|
1920
1993
|
category: "prompt_injection",
|
|
1921
1994
|
scope: "all",
|
|
1922
|
-
regex:
|
|
1995
|
+
regex: new RegExp(String.raw`disregard\s+${FILLER}(?:your|all|any)\s+${FILLER}(?:instructions|rules|guidelines)`, "i")
|
|
1923
1996
|
},
|
|
1924
1997
|
{
|
|
1925
1998
|
label: "system_prompt_override",
|
|
@@ -2072,7 +2145,7 @@ const PATTERNS = [
|
|
|
2072
2145
|
regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/
|
|
2073
2146
|
}
|
|
2074
2147
|
];
|
|
2075
|
-
const ZERO_WIDTH_CHARS =
|
|
2148
|
+
const ZERO_WIDTH_CHARS = new RegExp(`[\\u00ad\\u034f\\u061c\\u180e\\u200b\\u200c\\u200d\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\ufe00-\\ufe0f]|\\u{e0000}-\\u{e007f}`, "u");
|
|
2076
2149
|
const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
|
|
2077
2150
|
const SCOPE_ORDER = {
|
|
2078
2151
|
all: 1,
|
|
@@ -2262,12 +2335,17 @@ var MemoryStore = class {
|
|
|
2262
2335
|
/** V10-03 (P2-18): the strict-scan write gate. A block message names the hit
|
|
2263
2336
|
* label (scanMemoryThreats already embeds it) plus the self-heal hint. */
|
|
2264
2337
|
memoryThreatBlock(text) {
|
|
2265
|
-
|
|
2266
|
-
return threat === null ? null : threat + THREAT_EXEMPT_HINT;
|
|
2338
|
+
return scanMemoryThreats(text, void 0, this.threatScanOptions());
|
|
2267
2339
|
}
|
|
2268
2340
|
limitFor(target) {
|
|
2269
2341
|
return target === "memory" ? this.memoryLimit : this.userLimit;
|
|
2270
2342
|
}
|
|
2343
|
+
/** P2-1 (v18): the generated date prefix participates in duplicate detection
|
|
2344
|
+
* only when THIS store writes it. With addDatePrefix=false a fact's own
|
|
2345
|
+
* leading `## YYYY-MM-DD\n` is content, not a generated prefix. */
|
|
2346
|
+
dedupeKey(entry) {
|
|
2347
|
+
return this.addDatePrefix ? stripDatePrefix(entry) : entry;
|
|
2348
|
+
}
|
|
2271
2349
|
/**
|
|
2272
2350
|
* Read-guard probe: `{ size, limit }` when the on-disk file exceeds
|
|
2273
2351
|
* `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
|
|
@@ -2452,7 +2530,7 @@ var MemoryStore = class {
|
|
|
2452
2530
|
write: null
|
|
2453
2531
|
};
|
|
2454
2532
|
const entries = [...new Set(normalizeEntries(raw))];
|
|
2455
|
-
if (entries.some((entry) =>
|
|
2533
|
+
if (entries.some((entry) => this.dedupeKey(entry) === content)) {
|
|
2456
2534
|
this.resetFailures();
|
|
2457
2535
|
return {
|
|
2458
2536
|
result: {
|
|
@@ -2574,7 +2652,7 @@ var MemoryStore = class {
|
|
|
2574
2652
|
},
|
|
2575
2653
|
write: null
|
|
2576
2654
|
};
|
|
2577
|
-
if (!working.some((entry) =>
|
|
2655
|
+
if (!working.some((entry) => this.dedupeKey(entry) === body)) working.push(entryBody);
|
|
2578
2656
|
continue;
|
|
2579
2657
|
}
|
|
2580
2658
|
const rawAction = op.action;
|
|
@@ -2777,6 +2855,13 @@ async function recordMutation(root, io, record, cap = 500) {
|
|
|
2777
2855
|
console.warn(`mutation audit record dropped: ${mutationsFile(root)} is malformed and was not overwritten`);
|
|
2778
2856
|
return current;
|
|
2779
2857
|
}
|
|
2858
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
2859
|
+
const version = parsed.version;
|
|
2860
|
+
if (typeof version === "number" && version > 1) {
|
|
2861
|
+
console.warn(`mutation audit record dropped: ${mutationsFile(root)} declares version ${version} (newer than 1); not overwritten`);
|
|
2862
|
+
return current;
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2780
2865
|
const existing = recordsFromParsed(parsed);
|
|
2781
2866
|
existing.push(record);
|
|
2782
2867
|
const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
|
|
@@ -2897,7 +2982,9 @@ function clamp01(value) {
|
|
|
2897
2982
|
return Math.max(0, Math.min(1, value));
|
|
2898
2983
|
}
|
|
2899
2984
|
function daysBetween(from, now) {
|
|
2900
|
-
|
|
2985
|
+
const t = Date.parse(from);
|
|
2986
|
+
if (!Number.isFinite(t)) return 0;
|
|
2987
|
+
return Math.max(0, (now.getTime() - t) / 864e5);
|
|
2901
2988
|
}
|
|
2902
2989
|
function computeQualityScores(input) {
|
|
2903
2990
|
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
@@ -2906,9 +2993,9 @@ function computeQualityScores(input) {
|
|
|
2906
2993
|
const ageDays = Math.max(1, daysBetween(record.created_at, now));
|
|
2907
2994
|
const idleDays = daysBetween(latestActivityAt(record) ?? record.created_at, now);
|
|
2908
2995
|
const patchCount = record.patch_count;
|
|
2909
|
-
const
|
|
2910
|
-
const usageFrequency = clamp01(
|
|
2911
|
-
const stability =
|
|
2996
|
+
const loadCount = record.use_count + record.view_count;
|
|
2997
|
+
const usageFrequency = clamp01(loadCount / ageDays);
|
|
2998
|
+
const stability = loadCount === 0 ? 1 : clamp01(1 - patchCount / loadCount);
|
|
2912
2999
|
const recency = idleDays < 30 ? 1 : clamp01(1 - (idleDays - 30) / 150);
|
|
2913
3000
|
const references = clamp01((input.referenceCounts?.get(name) ?? 0) / 3);
|
|
2914
3001
|
const mutationMaturity = patchCount === 0 ? .3 : patchCount === 1 ? .4 : clamp01((patchCount - 1) / Math.max(1, ageDays / 30));
|
|
@@ -3053,7 +3140,7 @@ const SECRET_PATTERNS = [
|
|
|
3053
3140
|
["jwt", /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
|
|
3054
3141
|
["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
|
|
3055
3142
|
];
|
|
3056
|
-
const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("((
|
|
3143
|
+
const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]{0,64}[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]{0,64})?)\\b([\\s]*[:=][\\s]*)(?:\"([^\"\\r\\n]*)\"|'([^'\\r\\n]*)'|([^\\r\\n]+))", "gi");
|
|
3057
3144
|
/**
|
|
3058
3145
|
* Mask credential-shaped text before it crosses a session boundary.
|
|
3059
3146
|
* @param text - the text about to be sent to a model outside this session.
|
|
@@ -3062,7 +3149,7 @@ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("((?:\\b|[\\w-]+[_\
|
|
|
3062
3149
|
function redactSecrets(text) {
|
|
3063
3150
|
let out = text;
|
|
3064
3151
|
for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
|
|
3065
|
-
out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match,
|
|
3152
|
+
out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, lead, prefix, key, separator) => `${lead ?? ""}${prefix ?? ""}${key ?? ""}${separator ?? ""}<redacted>`);
|
|
3066
3153
|
return out;
|
|
3067
3154
|
}
|
|
3068
3155
|
//#endregion
|
|
@@ -3150,6 +3237,22 @@ const CORRECTION_PATTERNS = [
|
|
|
3150
3237
|
/remember\s+(?:this|that|to)/i
|
|
3151
3238
|
];
|
|
3152
3239
|
const FIX_PATTERNS = [/worked after|fixed by|the fix was|root cause/i, /retry(?:ing)? worked|workaround/i];
|
|
3240
|
+
/** Text of one persisted content block, or `''` for any other shape.
|
|
3241
|
+
*
|
|
3242
|
+
* Content blocks cross the durable session-log boundary, so their runtime
|
|
3243
|
+
* shape is `unknown` even where the static event type promises
|
|
3244
|
+
* `{ type, text }`: a persisted `content: [null]` (A2-7, v18) used to throw a
|
|
3245
|
+
* TypeError here and the review catch swallowed the whole turn's remaining
|
|
3246
|
+
* signals. Keeping the guard in one helper also keeps the branches free of
|
|
3247
|
+
* conditions the static type already excludes.
|
|
3248
|
+
* @param block - one element of a persisted message `content` array.
|
|
3249
|
+
* @returns the block's text when it is a text block, otherwise an empty string.
|
|
3250
|
+
*/
|
|
3251
|
+
function textOfBlock(block) {
|
|
3252
|
+
if (block === null || typeof block !== "object") return "";
|
|
3253
|
+
const candidate = block;
|
|
3254
|
+
return candidate.type === "text" && typeof candidate.text === "string" ? candidate.text : "";
|
|
3255
|
+
}
|
|
3153
3256
|
/** Fold one session event into the current turn observation. */
|
|
3154
3257
|
function observeEvent(signal, event) {
|
|
3155
3258
|
const data = event.data;
|
|
@@ -3157,7 +3260,7 @@ function observeEvent(signal, event) {
|
|
|
3157
3260
|
if (event.type === "user/message") {
|
|
3158
3261
|
const content = data.content;
|
|
3159
3262
|
if (!Array.isArray(content)) return;
|
|
3160
|
-
const text = content.map(
|
|
3263
|
+
const text = content.map(textOfBlock).join(" ");
|
|
3161
3264
|
signal.userChars += text.length;
|
|
3162
3265
|
if (CORRECTION_PATTERNS.some((pattern) => pattern.test(text))) signal.memorySignal = true;
|
|
3163
3266
|
if (FIX_PATTERNS.some((pattern) => pattern.test(text))) signal.skillSignal = true;
|
|
@@ -3166,7 +3269,7 @@ function observeEvent(signal, event) {
|
|
|
3166
3269
|
if (event.type === "assistant/message") {
|
|
3167
3270
|
const message = data.message;
|
|
3168
3271
|
if (!message || !Array.isArray(message.content)) return;
|
|
3169
|
-
const text = message.content.map(
|
|
3272
|
+
const text = message.content.map(textOfBlock).join(" ");
|
|
3170
3273
|
signal.assistantChars += text.length;
|
|
3171
3274
|
return;
|
|
3172
3275
|
}
|
|
@@ -3408,8 +3511,12 @@ const MAX_RESTRUCTURE_MOVES = 5;
|
|
|
3408
3511
|
* dots) used to pass here while every later patch/write/remove on it was
|
|
3409
3512
|
* refused as traversal (an orphan file the user could not touch). */
|
|
3410
3513
|
const RESTRUCTURE_TARGET_RE = /^references\/[a-z0-9](?!.*\.\.)[a-z0-9._-]*\.md$/;
|
|
3514
|
+
/** F-20 (v18): the character rule shared by support-file names and snapshot
|
|
3515
|
+
* `extras/` entry names. The two exported names used to carry the same literal
|
|
3516
|
+
* independently; both now derive from this one. */
|
|
3517
|
+
const SUPPORT_ENTRY_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
3411
3518
|
/** Extra file name carried inside a snapshot's `extras/` directory. */
|
|
3412
|
-
const SNAPSHOT_EXTRA_NAME_RE =
|
|
3519
|
+
const SNAPSHOT_EXTRA_NAME_RE = SUPPORT_ENTRY_NAME_RE;
|
|
3413
3520
|
function skillsRoot(env = process.env) {
|
|
3414
3521
|
return join(evolutionRoot(env), "skills");
|
|
3415
3522
|
}
|
|
@@ -3423,6 +3530,29 @@ function skillsRoot(env = process.env) {
|
|
|
3423
3530
|
function resolveSkillsRoot(config = {}) {
|
|
3424
3531
|
return (config.root ?? "").trim() || skillsRoot();
|
|
3425
3532
|
}
|
|
3533
|
+
/** E-7 (v18): every family row reads ONE root key. `root` is canonical;
|
|
3534
|
+
* `skillsRoot` is a deprecated alias honoured only while `root` is empty (so a
|
|
3535
|
+
* deployment that sets both keeps the canonical one) and removed after 0.3.65.
|
|
3536
|
+
* Callers log their own deprecation warning.
|
|
3537
|
+
* @param config - the raw plugin config, carrying `root` and/or `skillsRoot`.
|
|
3538
|
+
* @returns the effective root (empty when neither key is set) and whether the
|
|
3539
|
+
* deprecated alias supplied it.
|
|
3540
|
+
*/
|
|
3541
|
+
function resolveRootConfig(config = {}) {
|
|
3542
|
+
const root = (config.root ?? "").trim();
|
|
3543
|
+
if (root !== "") return {
|
|
3544
|
+
root,
|
|
3545
|
+
usedDeprecatedAlias: false
|
|
3546
|
+
};
|
|
3547
|
+
const alias = (config.skillsRoot ?? "").trim();
|
|
3548
|
+
return alias === "" ? {
|
|
3549
|
+
root: "",
|
|
3550
|
+
usedDeprecatedAlias: false
|
|
3551
|
+
} : {
|
|
3552
|
+
root: alias,
|
|
3553
|
+
usedDeprecatedAlias: true
|
|
3554
|
+
};
|
|
3555
|
+
}
|
|
3426
3556
|
/**
|
|
3427
3557
|
* Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
|
|
3428
3558
|
* the APPROVAL surface treats every delegated subagent as the autonomous
|
|
@@ -3459,6 +3589,15 @@ function skillDir(root, name) {
|
|
|
3459
3589
|
function markerEntryName(marker) {
|
|
3460
3590
|
return `.${marker}`;
|
|
3461
3591
|
}
|
|
3592
|
+
/** F-17 (v18): the root-level lock files a DESTRUCTIVE MOVER must treat as an
|
|
3593
|
+
* active writer (skill body + the two marker writers). Single source with
|
|
3594
|
+
* `markerEntryName`/`LOCK_SUFFIX` so a renamed marker cannot silently drop out
|
|
3595
|
+
* of the ghost-writer probe. */
|
|
3596
|
+
const MARKER_LOCK_NAMES = [
|
|
3597
|
+
`SKILL.md${LOCK_SUFFIX}`,
|
|
3598
|
+
`.pinned${LOCK_SUFFIX}`,
|
|
3599
|
+
`.hermes-managed${LOCK_SUFFIX}`
|
|
3600
|
+
];
|
|
3462
3601
|
function markerPath(dir, marker) {
|
|
3463
3602
|
return join(dir, markerEntryName(marker));
|
|
3464
3603
|
}
|
|
@@ -3711,7 +3850,7 @@ async function listNames(root, io) {
|
|
|
3711
3850
|
* `[a-z0-9._-]`) — drive-colon / odd-character / uppercase names can no
|
|
3712
3851
|
* longer reach the filesystem through writeSupportFile / patch /
|
|
3713
3852
|
* removeSupportFile. */
|
|
3714
|
-
const SUPPORT_FILE_NAME_RE =
|
|
3853
|
+
const SUPPORT_FILE_NAME_RE = SUPPORT_ENTRY_NAME_RE;
|
|
3715
3854
|
/** C-18: win32 reserves these stems with ANY extension (`nul.md` hits the
|
|
3716
3855
|
* NUL device), and they are fully inside the charset above — so the reserved
|
|
3717
3856
|
* set is checked on the first-dot prefix as well; the charset close alone
|
|
@@ -3742,6 +3881,16 @@ const WIN32_RESERVED_DEVICE_NAMES = new Set([
|
|
|
3742
3881
|
"lpt8",
|
|
3743
3882
|
"lpt9"
|
|
3744
3883
|
]);
|
|
3884
|
+
/** A1-6 (v18): the regex alone admits `references/nul.md` (a Windows device
|
|
3885
|
+
* stem), which the support-file layer refuses. Restructure must use the same
|
|
3886
|
+
* rule, or it creates an orphan the later patch/write/remove paths refuse.
|
|
3887
|
+
* Single source shared with the plan validator. */
|
|
3888
|
+
function validateRestructureTarget(filePath) {
|
|
3889
|
+
if (!RESTRUCTURE_TARGET_RE.test(filePath)) return `toFile must be references/<topic>.md (got "${filePath}").`;
|
|
3890
|
+
const stem = filePath.slice(filePath.lastIndexOf("/") + 1).split(".")[0]?.toLowerCase() ?? "";
|
|
3891
|
+
if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `toFile "${filePath}" uses a Windows reserved device name.`;
|
|
3892
|
+
return null;
|
|
3893
|
+
}
|
|
3745
3894
|
function validateSupportPath(filePath) {
|
|
3746
3895
|
const normalized = filePath.replace(/\\/g, "/");
|
|
3747
3896
|
if (normalized.includes("..")) return "Path traversal is not allowed.";
|
|
@@ -3751,6 +3900,7 @@ function validateSupportPath(filePath) {
|
|
|
3751
3900
|
for (const part of parts.slice(1)) {
|
|
3752
3901
|
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
3902
|
if (part.toLowerCase().endsWith(".lock")) return `Unsupported file name "${part}" — the .lock suffix is reserved for the writer-lock protocol.`;
|
|
3903
|
+
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
3904
|
const stem = part.split(".")[0]?.toLowerCase() ?? "";
|
|
3755
3905
|
if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `Unsupported file name "${part}" — a Windows reserved device name.`;
|
|
3756
3906
|
}
|
|
@@ -3958,11 +4108,23 @@ var SkillLibrary = class {
|
|
|
3958
4108
|
outcome = o;
|
|
3959
4109
|
return o.write ?? current ?? null;
|
|
3960
4110
|
};
|
|
3961
|
-
|
|
4111
|
+
let durabilityWarning = "";
|
|
4112
|
+
const committedOnly = (error) => error?.committed === true;
|
|
4113
|
+
if (this.transact) try {
|
|
4114
|
+
await this.transact(this.io, path, run);
|
|
4115
|
+
} catch (error) {
|
|
4116
|
+
if (!committedOnly(error)) throw error;
|
|
4117
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
4118
|
+
}
|
|
3962
4119
|
else {
|
|
3963
4120
|
const current = await this.io.readText(path);
|
|
3964
4121
|
const next = await run(current);
|
|
3965
|
-
if (next !== null && next !== current)
|
|
4122
|
+
if (next !== null && next !== current) try {
|
|
4123
|
+
await this.io.writeText(path, next);
|
|
4124
|
+
} catch (error) {
|
|
4125
|
+
if (!committedOnly(error)) throw error;
|
|
4126
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
4127
|
+
}
|
|
3966
4128
|
}
|
|
3967
4129
|
const o = outcome;
|
|
3968
4130
|
if (o === void 0 || typeof o !== "object" || !Object.prototype.hasOwnProperty.call(o, "write")) return {
|
|
@@ -3971,7 +4133,10 @@ var SkillLibrary = class {
|
|
|
3971
4133
|
};
|
|
3972
4134
|
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
4135
|
if (o.write !== null && o.event) this.notifyMutation(o.event);
|
|
3974
|
-
return o.result
|
|
4136
|
+
return durabilityWarning === "" || !o.result.ok ? o.result : {
|
|
4137
|
+
...o.result,
|
|
4138
|
+
message: `${o.result.message} (warning: the write landed but the directory fsync failed — durability unconfirmed: ${durabilityWarning})`
|
|
4139
|
+
};
|
|
3975
4140
|
}
|
|
3976
4141
|
/** Notify the mutation observer after a successful write; observers must never fail the mutation. */
|
|
3977
4142
|
notifyMutation(event) {
|
|
@@ -3988,8 +4153,7 @@ var SkillLibrary = class {
|
|
|
3988
4153
|
* label (scanContentThreats already embeds it) plus the self-heal hint, so a
|
|
3989
4154
|
* false-positive rewrite direction is actionable instead of a dead end. */
|
|
3990
4155
|
contentThreatBlock(content) {
|
|
3991
|
-
|
|
3992
|
-
return threat === null ? null : threat + THREAT_EXEMPT_HINT;
|
|
4156
|
+
return scanContentThreats(content, void 0, this.threatScanOptions());
|
|
3993
4157
|
}
|
|
3994
4158
|
async list() {
|
|
3995
4159
|
const summaries = [];
|
|
@@ -3998,20 +4162,42 @@ var SkillLibrary = class {
|
|
|
3998
4162
|
const md = await this.io.readText(join(dir, "SKILL.md"));
|
|
3999
4163
|
if (md === null) continue;
|
|
4000
4164
|
const parsed = parseFrontmatter(md);
|
|
4001
|
-
let entries =
|
|
4165
|
+
let entries = null;
|
|
4002
4166
|
try {
|
|
4003
4167
|
entries = await this.io.list(dir);
|
|
4004
|
-
} catch {
|
|
4005
|
-
|
|
4006
|
-
|
|
4168
|
+
} catch {
|
|
4169
|
+
entries = null;
|
|
4170
|
+
}
|
|
4171
|
+
const probeMarker = async (marker) => {
|
|
4172
|
+
if (entries !== null) return entries.includes(markerEntryName(marker));
|
|
4173
|
+
try {
|
|
4174
|
+
return await this.io.exists(join(dir, markerEntryName(marker)));
|
|
4175
|
+
} catch {
|
|
4176
|
+
return null;
|
|
4177
|
+
}
|
|
4178
|
+
};
|
|
4179
|
+
const [bundled, hubInstalled, pinned, hermesManaged] = await Promise.all([
|
|
4180
|
+
probeMarker("bundled"),
|
|
4181
|
+
probeMarker("hub-installed"),
|
|
4182
|
+
probeMarker("pinned"),
|
|
4183
|
+
probeMarker("hermes-managed")
|
|
4184
|
+
]);
|
|
4185
|
+
const protectedBy = bundled === true ? "bundled" : hubInstalled === true ? "hub-installed" : pinned === true ? "pinned" : null;
|
|
4007
4186
|
const parsedDescription = parsed?.frontmatter.description;
|
|
4187
|
+
const parsedWhenToUse = parsed?.frontmatter.whenToUse;
|
|
4008
4188
|
summaries.push({
|
|
4009
4189
|
name,
|
|
4010
4190
|
description: typeof parsedDescription === "string" ? parsedDescription : "",
|
|
4011
4191
|
path: dir,
|
|
4012
4192
|
protectedBy,
|
|
4013
|
-
|
|
4014
|
-
|
|
4193
|
+
protectionUnknown: [
|
|
4194
|
+
bundled,
|
|
4195
|
+
hubInstalled,
|
|
4196
|
+
pinned,
|
|
4197
|
+
hermesManaged
|
|
4198
|
+
].some((value) => value === null),
|
|
4199
|
+
managed: hermesManaged === true,
|
|
4200
|
+
...typeof parsedWhenToUse === "string" && parsedWhenToUse.trim() !== "" ? { whenToUse: parsedWhenToUse } : {}
|
|
4015
4201
|
});
|
|
4016
4202
|
}
|
|
4017
4203
|
return summaries;
|
|
@@ -4193,6 +4379,9 @@ var SkillLibrary = class {
|
|
|
4193
4379
|
* marker write is the only state change; content is untouched.
|
|
4194
4380
|
*/
|
|
4195
4381
|
async setPinned(name, pinned, origin = "foreground") {
|
|
4382
|
+
return await this.serial(() => this.setPinnedCore(name, pinned, origin));
|
|
4383
|
+
}
|
|
4384
|
+
async setPinnedCore(name, pinned, origin) {
|
|
4196
4385
|
const normalized = name.trim();
|
|
4197
4386
|
const bad = this.badName(normalized);
|
|
4198
4387
|
if (bad) return {
|
|
@@ -4275,15 +4464,26 @@ var SkillLibrary = class {
|
|
|
4275
4464
|
const onDisk = finalContent.trimEnd() + "\n";
|
|
4276
4465
|
const createPath = join(dir, "SKILL.md");
|
|
4277
4466
|
let existsAtCommit = false;
|
|
4467
|
+
let taskRan = false;
|
|
4278
4468
|
if (this.transact) await this.transact(this.io, createPath, (current) => {
|
|
4469
|
+
taskRan = true;
|
|
4279
4470
|
if (current !== null) {
|
|
4280
4471
|
existsAtCommit = true;
|
|
4281
4472
|
return current;
|
|
4282
4473
|
}
|
|
4283
4474
|
return onDisk;
|
|
4284
4475
|
});
|
|
4285
|
-
else if (await this.io.exists(createPath))
|
|
4286
|
-
|
|
4476
|
+
else if (await this.io.exists(createPath)) {
|
|
4477
|
+
taskRan = true;
|
|
4478
|
+
existsAtCommit = true;
|
|
4479
|
+
} else {
|
|
4480
|
+
taskRan = true;
|
|
4481
|
+
await this.io.writeText(createPath, onDisk);
|
|
4482
|
+
}
|
|
4483
|
+
if (!taskRan) return {
|
|
4484
|
+
ok: false,
|
|
4485
|
+
message: "internal error: the create transaction did not invoke the task; no file was written"
|
|
4486
|
+
};
|
|
4287
4487
|
if (existsAtCommit) return {
|
|
4288
4488
|
ok: false,
|
|
4289
4489
|
message: `Skill "${normalized}" already exists.`
|
|
@@ -4583,11 +4783,7 @@ var SkillLibrary = class {
|
|
|
4583
4783
|
* CRASHED writer also refuses — correct: inspect, don't archive.
|
|
4584
4784
|
*/
|
|
4585
4785
|
async hasWriteLock(dir) {
|
|
4586
|
-
const markerLocks =
|
|
4587
|
-
join(dir, "SKILL.md.lock"),
|
|
4588
|
-
join(dir, ".pinned.lock"),
|
|
4589
|
-
join(dir, ".hermes-managed.lock")
|
|
4590
|
-
];
|
|
4786
|
+
const markerLocks = MARKER_LOCK_NAMES.map((name) => join(dir, name));
|
|
4591
4787
|
for (const lock of markerLocks) if (await this.isWriterLock(lock)) return true;
|
|
4592
4788
|
for (const supportDir of SUPPORT_DIRS) {
|
|
4593
4789
|
let entries;
|
|
@@ -4609,17 +4805,25 @@ var SkillLibrary = class {
|
|
|
4609
4805
|
* or be swept as residue — the v16 first cut matched on suffix alone,
|
|
4610
4806
|
* which permanently refused archiving and deleted user content on restore. */
|
|
4611
4807
|
async isWriterLock(lockPath) {
|
|
4612
|
-
|
|
4808
|
+
let body;
|
|
4809
|
+
try {
|
|
4810
|
+
body = await this.io.readText(lockPath);
|
|
4811
|
+
} catch {
|
|
4812
|
+
return true;
|
|
4813
|
+
}
|
|
4613
4814
|
if (body === null) return false;
|
|
4614
|
-
return
|
|
4815
|
+
return LOCK_BODY_RE.test(body.trim());
|
|
4615
4816
|
}
|
|
4616
|
-
/** P2 (v16): best-effort removal of lock residue inside a RESTORED tree
|
|
4617
|
-
*
|
|
4618
|
-
*
|
|
4619
|
-
*
|
|
4620
|
-
*
|
|
4817
|
+
/** P2 (v16): best-effort removal of lock residue inside a RESTORED tree.
|
|
4818
|
+
* A1-2/A1-7 (v18): the sweep now covers the marker locks the probe checks
|
|
4819
|
+
* (`SKILL.md.lock`/`.pinned.lock`/`.hermes-managed.lock`) and only removes
|
|
4820
|
+
* a lock whose holder pid is NOT alive — a live writer's lock is never
|
|
4821
|
+
* stolen by the sweep. A dead-pid residue would otherwise permanently
|
|
4822
|
+
* refuse archive/restore. */
|
|
4621
4823
|
async deleteStrandedLocks(dir) {
|
|
4622
4824
|
await this.sweepLockIfStranded(join(dir, "SKILL.md.lock"));
|
|
4825
|
+
await this.sweepLockIfStranded(join(dir, ".pinned.lock"));
|
|
4826
|
+
await this.sweepLockIfStranded(join(dir, ".hermes-managed.lock"));
|
|
4623
4827
|
for (const supportDir of SUPPORT_DIRS) {
|
|
4624
4828
|
let entries = [];
|
|
4625
4829
|
try {
|
|
@@ -4631,10 +4835,33 @@ var SkillLibrary = class {
|
|
|
4631
4835
|
}
|
|
4632
4836
|
}
|
|
4633
4837
|
/** Remove `lockPath` only when its body has the writer-lock `pid:token`
|
|
4634
|
-
* shape; anything else (a user support file
|
|
4838
|
+
* shape AND the holder pid is not alive; anything else (a user support file
|
|
4839
|
+
* or a live writer's lock) is left untouched. */
|
|
4635
4840
|
async sweepLockIfStranded(lockPath) {
|
|
4636
4841
|
const body = await this.io.readText(lockPath).catch(() => null);
|
|
4637
|
-
if (body === null
|
|
4842
|
+
if (body === null) return;
|
|
4843
|
+
const match = /^(\d+):[0-9a-f]*$/.exec(body.trim());
|
|
4844
|
+
if (match === null) return;
|
|
4845
|
+
const pid = Number(match[1]);
|
|
4846
|
+
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return;
|
|
4847
|
+
await this.io.remove(lockPath).catch(() => {});
|
|
4848
|
+
}
|
|
4849
|
+
/** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
|
|
4850
|
+
* only a single, non-traversing path component is safe. Dotfiles
|
|
4851
|
+
* (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed. */
|
|
4852
|
+
safeSnapshotEntryName(name) {
|
|
4853
|
+
return name !== "" && name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\") && basename(name) === name;
|
|
4854
|
+
}
|
|
4855
|
+
/** A1-7 (v18): a root-level lock whose holder is alive must refuse the
|
|
4856
|
+
* restore; a dead residue is swept so a crashed writer cannot block
|
|
4857
|
+
* recovery. A non-lock body shape is left alone (user file). */
|
|
4858
|
+
async refuseLiveLockOrSweep(lockPath, label) {
|
|
4859
|
+
const body = await this.io.readText(lockPath).catch(() => null);
|
|
4860
|
+
if (body === null) return;
|
|
4861
|
+
const match = /^(\d+):[0-9a-f]*$/.exec(body.trim());
|
|
4862
|
+
if (match === null) return;
|
|
4863
|
+
const pid = Number(match[1]);
|
|
4864
|
+
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
4865
|
await this.io.remove(lockPath).catch(() => {});
|
|
4639
4866
|
}
|
|
4640
4867
|
async archive(rawName, options = {}) {
|
|
@@ -4921,9 +5148,10 @@ var SkillLibrary = class {
|
|
|
4921
5148
|
ok: false,
|
|
4922
5149
|
message: "Every restructure move needs a non-empty heading."
|
|
4923
5150
|
};
|
|
4924
|
-
|
|
5151
|
+
const targetIssue = validateRestructureTarget(move.toFile);
|
|
5152
|
+
if (targetIssue) return {
|
|
4925
5153
|
ok: false,
|
|
4926
|
-
message:
|
|
5154
|
+
message: targetIssue
|
|
4927
5155
|
};
|
|
4928
5156
|
}
|
|
4929
5157
|
const dir = this.dirOf(name);
|
|
@@ -5020,16 +5248,17 @@ var SkillLibrary = class {
|
|
|
5020
5248
|
ok: false,
|
|
5021
5249
|
message: `Skill "${name}" is protected (${protection}).`
|
|
5022
5250
|
};
|
|
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
5251
|
const landing = [];
|
|
5031
5252
|
for (const write of plan.writes) {
|
|
5032
|
-
|
|
5253
|
+
let previous;
|
|
5254
|
+
try {
|
|
5255
|
+
previous = await this.io.readText(write.target);
|
|
5256
|
+
} catch (error) {
|
|
5257
|
+
return {
|
|
5258
|
+
ok: false,
|
|
5259
|
+
message: `Tree change refused: cannot safely pre-read ${write.target} (${error instanceof Error ? error.message : String(error)}); no writes were performed`
|
|
5260
|
+
};
|
|
5261
|
+
}
|
|
5033
5262
|
if (Buffer.byteLength(write.content, "utf8") > this.limits.maxSkillFileBytes) return {
|
|
5034
5263
|
ok: false,
|
|
5035
5264
|
message: `Write exceeds ${this.limits.maxSkillFileBytes} bytes: ${write.target}`
|
|
@@ -5045,18 +5274,16 @@ var SkillLibrary = class {
|
|
|
5045
5274
|
previous
|
|
5046
5275
|
});
|
|
5047
5276
|
}
|
|
5048
|
-
const semantic = plan.validate?.({
|
|
5049
|
-
dir,
|
|
5050
|
-
currentMd: md
|
|
5051
|
-
}) ?? null;
|
|
5052
|
-
if (semantic) return {
|
|
5053
|
-
ok: false,
|
|
5054
|
-
message: semantic
|
|
5055
|
-
};
|
|
5056
5277
|
const written = [];
|
|
5278
|
+
let durabilityWarning = "";
|
|
5057
5279
|
try {
|
|
5058
5280
|
for (const entry of landing) {
|
|
5059
|
-
|
|
5281
|
+
try {
|
|
5282
|
+
await this.io.writeText(entry.target, entry.content);
|
|
5283
|
+
} catch (error) {
|
|
5284
|
+
if (error?.committed !== true) throw error;
|
|
5285
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
5286
|
+
}
|
|
5060
5287
|
written.push({
|
|
5061
5288
|
target: entry.target,
|
|
5062
5289
|
previous: entry.previous
|
|
@@ -5077,7 +5304,7 @@ var SkillLibrary = class {
|
|
|
5077
5304
|
});
|
|
5078
5305
|
return {
|
|
5079
5306
|
ok: true,
|
|
5080
|
-
message: `${plan.eventAction} "${name}" succeeded
|
|
5307
|
+
message: durabilityWarning === "" ? `${plan.eventAction} "${name}" succeeded.` : `${plan.eventAction} "${name}" succeeded (warning: the write landed but the directory fsync failed — durability unconfirmed: ${durabilityWarning})`,
|
|
5081
5308
|
path: dir
|
|
5082
5309
|
};
|
|
5083
5310
|
}
|
|
@@ -5093,9 +5320,10 @@ var SkillLibrary = class {
|
|
|
5093
5320
|
ok: false,
|
|
5094
5321
|
message: bad
|
|
5095
5322
|
};
|
|
5096
|
-
|
|
5323
|
+
const dest = this.dirOf(name);
|
|
5324
|
+
if (await this.io.exists(dest)) return {
|
|
5097
5325
|
ok: false,
|
|
5098
|
-
message: `Skill "${name}" already exists in the active root; refusing to overwrite.`
|
|
5326
|
+
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
5327
|
};
|
|
5100
5328
|
const archiveRoot = join(this.root, ".archive");
|
|
5101
5329
|
let entries;
|
|
@@ -5118,7 +5346,6 @@ var SkillLibrary = class {
|
|
|
5118
5346
|
message: `Skill "${name}" is not in .archive.`
|
|
5119
5347
|
};
|
|
5120
5348
|
const source = join(archiveRoot, chosen);
|
|
5121
|
-
const dest = this.dirOf(name);
|
|
5122
5349
|
if (this.io.isSymlink) {
|
|
5123
5350
|
if (await this.io.isSymlink(source) === true) return {
|
|
5124
5351
|
ok: false,
|
|
@@ -5251,7 +5478,8 @@ var SkillLibrary = class {
|
|
|
5251
5478
|
ok: false,
|
|
5252
5479
|
message: `"${filePath}" is not a readable regular file — remove the files inside it one by one.`
|
|
5253
5480
|
};
|
|
5254
|
-
await this.io
|
|
5481
|
+
if (this.transact) await this.transact(this.io, target, () => null);
|
|
5482
|
+
else await this.io.remove(target);
|
|
5255
5483
|
await this.audit(name, "remove_file", before, null, `removed ${filePath}`);
|
|
5256
5484
|
this.notifyMutation({
|
|
5257
5485
|
action: "remove_file",
|
|
@@ -5278,9 +5506,10 @@ var SkillLibrary = class {
|
|
|
5278
5506
|
while (await this.io.exists(dest)) dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
|
|
5279
5507
|
try {
|
|
5280
5508
|
const names = await listNames(this.root, this.io);
|
|
5281
|
-
await Promise.
|
|
5509
|
+
const copyFailure = (await Promise.allSettled(names.map(async (name) => {
|
|
5282
5510
|
await this.io.copy(this.dirOf(name), join(dest, name));
|
|
5283
|
-
}));
|
|
5511
|
+
}))).find((result) => result.status === "rejected");
|
|
5512
|
+
if (copyFailure) throw copyFailure.reason;
|
|
5284
5513
|
const sidecars = [];
|
|
5285
5514
|
for (const sidecar of [usageFile(this.root), suppressedFile(this.root)]) if (await this.io.exists(sidecar)) {
|
|
5286
5515
|
const name = basename(sidecar);
|
|
@@ -5295,9 +5524,10 @@ var SkillLibrary = class {
|
|
|
5295
5524
|
}
|
|
5296
5525
|
const validExtras = extras.filter((extra) => SNAPSHOT_EXTRA_NAME_RE.test(extra.name));
|
|
5297
5526
|
const extraNames = validExtras.map((extra) => extra.name);
|
|
5298
|
-
await Promise.
|
|
5527
|
+
const extraFailure = (await Promise.allSettled(validExtras.map(async (extra) => {
|
|
5299
5528
|
await this.io.writeText(join(dest, "extras", extra.name), extra.content);
|
|
5300
|
-
}));
|
|
5529
|
+
}))).find((result) => result.status === "rejected");
|
|
5530
|
+
if (extraFailure) throw extraFailure.reason;
|
|
5301
5531
|
await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
|
|
5302
5532
|
reason,
|
|
5303
5533
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -5319,10 +5549,11 @@ var SkillLibrary = class {
|
|
|
5319
5549
|
if (raw === null) return null;
|
|
5320
5550
|
try {
|
|
5321
5551
|
const manifest = JSON.parse(raw);
|
|
5552
|
+
if (!Array.isArray(manifest.skills)) return null;
|
|
5322
5553
|
return {
|
|
5323
5554
|
reason: typeof manifest.reason === "string" ? manifest.reason : "",
|
|
5324
5555
|
createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "",
|
|
5325
|
-
skills:
|
|
5556
|
+
skills: manifest.skills,
|
|
5326
5557
|
sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
|
|
5327
5558
|
...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
|
|
5328
5559
|
extras: Array.isArray(manifest.extras) ? manifest.extras : []
|
|
@@ -5357,6 +5588,7 @@ var SkillLibrary = class {
|
|
|
5357
5588
|
reason: manifest.reason
|
|
5358
5589
|
});
|
|
5359
5590
|
}
|
|
5591
|
+
out.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || "") || b.path.localeCompare(a.path));
|
|
5360
5592
|
return out;
|
|
5361
5593
|
}
|
|
5362
5594
|
/**
|
|
@@ -5427,6 +5659,15 @@ var SkillLibrary = class {
|
|
|
5427
5659
|
* restoreLatestSnapshot so a failed restore can roll itself back (E-13).
|
|
5428
5660
|
*/
|
|
5429
5661
|
async restoreSnapshotIntoRoot(snapshotPath) {
|
|
5662
|
+
const manifest = await this.readSnapshotManifest(snapshotPath);
|
|
5663
|
+
if (manifest === null) {
|
|
5664
|
+
if (await this.io.exists(join(snapshotPath, "manifest.json"))) throw new Error(`snapshot ${snapshotPath} has an unreadable manifest.json; refusing to clear the active tree`);
|
|
5665
|
+
} else {
|
|
5666
|
+
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`);
|
|
5667
|
+
if (manifest.skills.length === 0) {
|
|
5668
|
+
if ((await this.io.list(snapshotPath)).some((entry) => entry !== "manifest.json" && entry !== "extras" && entry !== ".archive")) throw new Error(`snapshot ${snapshotPath} declares no skills but contains entries; refusing to clear the active tree`);
|
|
5669
|
+
}
|
|
5670
|
+
}
|
|
5430
5671
|
let rootEntries;
|
|
5431
5672
|
try {
|
|
5432
5673
|
rootEntries = await this.io.list(this.root);
|
|
@@ -5435,13 +5676,24 @@ var SkillLibrary = class {
|
|
|
5435
5676
|
}
|
|
5436
5677
|
for (const entry of rootEntries) {
|
|
5437
5678
|
if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json" || entry === ".curator-suppressed.json") continue;
|
|
5438
|
-
|
|
5679
|
+
if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) {
|
|
5680
|
+
await this.refuseLiveLockOrSweep(join(this.root, entry), entry);
|
|
5681
|
+
continue;
|
|
5682
|
+
}
|
|
5683
|
+
const dir = join(this.root, entry);
|
|
5684
|
+
if (await this.io.exists(join(dir, "SKILL.md"))) {
|
|
5685
|
+
await this.deleteStrandedLocks(dir);
|
|
5686
|
+
if (await this.hasWriteLock(dir)) throw new Error(`snapshot restore refused: skill "${entry}" is being written (write lock present); retry once the write completes`);
|
|
5687
|
+
}
|
|
5439
5688
|
}
|
|
5440
|
-
const
|
|
5441
|
-
|
|
5442
|
-
if (entry === "
|
|
5443
|
-
|
|
5689
|
+
const restoresSuppressed = manifest !== null && manifest.sidecars.includes(".curator-suppressed.json");
|
|
5690
|
+
for (const entry of rootEntries) {
|
|
5691
|
+
if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json") continue;
|
|
5692
|
+
if (entry === ".curator-suppressed.json" && restoresSuppressed) continue;
|
|
5693
|
+
if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) continue;
|
|
5694
|
+
await this.io.remove(join(this.root, entry));
|
|
5444
5695
|
}
|
|
5696
|
+
if (manifest === null) throw new Error(`snapshot ${snapshotPath} has no readable manifest.json; refusing to restore`);
|
|
5445
5697
|
else {
|
|
5446
5698
|
for (const name of manifest.skills) await this.io.copy(join(snapshotPath, name), join(this.root, name));
|
|
5447
5699
|
for (const sidecar of manifest.sidecars) await this.io.copy(join(snapshotPath, sidecar), join(this.root, sidecar));
|
|
@@ -5458,4 +5710,4 @@ var SkillLibrary = class {
|
|
|
5458
5710
|
}
|
|
5459
5711
|
};
|
|
5460
5712
|
//#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,
|
|
5713
|
+
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 };
|
package/lib/types/constants.d.ts
CHANGED
|
@@ -19,7 +19,13 @@
|
|
|
19
19
|
* threshold, which are intentionally left where they are used.
|
|
20
20
|
* @module @lmzhen/dsh-evolution-core
|
|
21
21
|
*/
|
|
22
|
-
/** Skill frontmatter `name` validated for the file name (lowercase + hyphen).
|
|
22
|
+
/** Skill frontmatter `name` validated for the file name (lowercase + hyphen).
|
|
23
|
+
* 计划 B-4 (v18): tightened to the UPSTREAM `SKILL_NAME` shape
|
|
24
|
+
* (`/^[a-z0-9]+(?:-[a-z0-9]+)*$/`, packages/skill/skill/src/index.ts:20). The
|
|
25
|
+
* old form admitted trailing/consecutive hyphens, which upstream
|
|
26
|
+
* `validateCandidate` throws on — and that throw aborts the WHOLE `ctx.skills`
|
|
27
|
+
* collection. The catalog provider still filters such legacy tree entries so
|
|
28
|
+
* an existing tree cannot break a session. */
|
|
23
29
|
export declare const SKILL_NAME_RE: RegExp;
|
|
24
30
|
/** Allowed skill support-file subdirectories (path-traversal boundary). */
|
|
25
31
|
export declare const SUPPORT_DIRS: readonly ["references", "templates", "scripts", "assets"];
|
|
@@ -53,6 +59,9 @@ export declare const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
|
|
|
53
59
|
export declare const DEFAULT_USER_CHAR_LIMIT = 1375;
|
|
54
60
|
/** Consolidation-failure backoff cap, shared by MemoryStore and memory-files' Config default. */
|
|
55
61
|
export declare const DEFAULT_CONSOLIDATION_FAILURES = 3;
|
|
62
|
+
/** F-20 (v18): the authored-body budget and the hard ceiling are the same
|
|
63
|
+
* number today. Derive it so a future divergence is one edit, not two names
|
|
64
|
+
* that silently disagree. */
|
|
56
65
|
export declare const DEFAULT_SKILL_CONTENT_CHARS = 100000;
|
|
57
66
|
/** P3-19 (v14): defaults that were written twice (schema `.default()` AND the
|
|
58
67
|
* clamp fallback literal) now have one home per value. */
|
package/lib/types/events.d.ts
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
* these events off `session.append`). Plan-outcome durability lives in the
|
|
13
13
|
* evolution-activity store, not the session log.
|
|
14
14
|
*/
|
|
15
|
+
import type { ReviewKind } from './signals.ts';
|
|
15
16
|
export interface EvolutionReviewScheduledEvent {
|
|
16
17
|
/** Owning session (payload v2): process events carry no session envelope. */
|
|
17
18
|
sessionId: string;
|
|
18
|
-
|
|
19
|
+
/** F-20 (v18): single definition point — `ReviewKind` in signals.ts. */
|
|
20
|
+
kind: ReviewKind;
|
|
19
21
|
toolCalls: number;
|
|
20
22
|
userChars: number;
|
|
21
23
|
assistantChars: number;
|
|
@@ -68,6 +68,17 @@ export interface EvolutionEvent {
|
|
|
68
68
|
} | undefined;
|
|
69
69
|
}
|
|
70
70
|
export declare function eventsFile(home: string): string;
|
|
71
|
+
/** I-5 (v18): one lightweight description of the durable event payload
|
|
72
|
+
* contract. The log is a FILE boundary (a host, a script or an older version
|
|
73
|
+
* can write it), so `appendEvolutionEvent` refuses a record no consumer can
|
|
74
|
+
* fold instead of persisting it and failing silently later. The process event
|
|
75
|
+
* bus stays unvalidated — that is a typed same-process boundary.
|
|
76
|
+
* @param event - the candidate event record.
|
|
77
|
+
* @returns a human-readable issue, or null when the record is well-formed.
|
|
78
|
+
*/
|
|
79
|
+
export declare function evolutionEventPayloadIssue(event: {
|
|
80
|
+
type?: unknown;
|
|
81
|
+
} & Partial<EvolutionEvent>): string | null;
|
|
71
82
|
export declare function parseEvolutionEvents(raw: string | null): EvolutionEvent[];
|
|
72
83
|
/**
|
|
73
84
|
* List the numeric archives under the log's directory, sorted ascending by
|
package/lib/types/io.d.ts
CHANGED
|
@@ -106,6 +106,21 @@ export declare function renameWithRetry(tmp: string, target: string, fn?: (from:
|
|
|
106
106
|
* failing `handle.sync()` deterministically.
|
|
107
107
|
*/
|
|
108
108
|
export declare function writeDurableTmp(target: string, content: string, openImpl?: typeof open): Promise<string>;
|
|
109
|
+
/**
|
|
110
|
+
* True when the pid is alive (EPERM = alive but unowned; ESRCH = gone).
|
|
111
|
+
* V18 single source: the node backend's lock takeover and SkillLibrary's
|
|
112
|
+
* stranded-lock sweep must use the same liveness rule.
|
|
113
|
+
*/
|
|
114
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
115
|
+
/** F-17 (v18): the write-lock protocol is a cross-module contract — the lock
|
|
116
|
+
* file is `<target>.lock` and its body is `<pid>:<token>`. This module creates
|
|
117
|
+
* them (`withWriteLock`) and `skill-store`'s probes/sweepers parse them, so both
|
|
118
|
+
* consume these two constants instead of repeating the literals. */
|
|
119
|
+
export declare const LOCK_SUFFIX = ".lock";
|
|
120
|
+
/** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
|
|
121
|
+
* torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
|
|
122
|
+
* the pid part is intact, which is what the takeover probe needs. */
|
|
123
|
+
export declare const LOCK_BODY_RE: RegExp;
|
|
109
124
|
/**
|
|
110
125
|
* Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
|
|
111
126
|
* (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
|
|
@@ -57,6 +57,10 @@ export declare class MemoryStore {
|
|
|
57
57
|
* label (scanMemoryThreats already embeds it) plus the self-heal hint. */
|
|
58
58
|
private memoryThreatBlock;
|
|
59
59
|
limitFor(target: MemoryTarget): number;
|
|
60
|
+
/** P2-1 (v18): the generated date prefix participates in duplicate detection
|
|
61
|
+
* only when THIS store writes it. With addDatePrefix=false a fact's own
|
|
62
|
+
* leading `## YYYY-MM-DD\n` is content, not a generated prefix. */
|
|
63
|
+
private dedupeKey;
|
|
60
64
|
/**
|
|
61
65
|
* Read-guard probe: `{ size, limit }` when the on-disk file exceeds
|
|
62
66
|
* `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
|
package/lib/types/quality.d.ts
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { UsageMap } from './usage.ts';
|
|
13
13
|
export interface QualityFactors {
|
|
14
|
-
/** 0.25 —
|
|
14
|
+
/** 0.25 — skill LOADS per day of age (view_count + use_count), capped at 1. */
|
|
15
15
|
usageFrequency: number;
|
|
16
|
-
/** 0.20 — 1 − patch/
|
|
16
|
+
/** 0.20 — 1 − patch/load (zero loads = stable). */
|
|
17
17
|
stability: number;
|
|
18
18
|
/** 0.20 — 1 under 30 idle days, linear decay to 0 at 180. */
|
|
19
19
|
recency: number;
|
|
@@ -22,8 +22,15 @@ export interface SkillSummary {
|
|
|
22
22
|
description: string;
|
|
23
23
|
path: string;
|
|
24
24
|
protectedBy: string | null;
|
|
25
|
+
/** A1-17 (v18): the marker probe itself failed (EACCES/EIO), so "no marker"
|
|
26
|
+
* cannot be told apart from "directory unreadable". Consumers must treat this
|
|
27
|
+
* as protected, never as unprotected. */
|
|
28
|
+
protectionUnknown: boolean;
|
|
25
29
|
managed: boolean;
|
|
26
|
-
|
|
30
|
+
/** E-11 (v18): the frontmatter `whenToUse` routing hint, published so the
|
|
31
|
+
* platform catalog keeps it while this provider shadows the upstream
|
|
32
|
+
* filesystem provider. Absent when the frontmatter has none. */
|
|
33
|
+
whenToUse?: string;
|
|
27
34
|
}
|
|
28
35
|
export interface SkillActionResult {
|
|
29
36
|
ok: boolean;
|
|
@@ -99,6 +106,21 @@ export declare function skillsRoot(env?: NodeJS.ProcessEnv): string;
|
|
|
99
106
|
export declare function resolveSkillsRoot(config?: {
|
|
100
107
|
root?: string | undefined;
|
|
101
108
|
}): string;
|
|
109
|
+
/** E-7 (v18): every family row reads ONE root key. `root` is canonical;
|
|
110
|
+
* `skillsRoot` is a deprecated alias honoured only while `root` is empty (so a
|
|
111
|
+
* deployment that sets both keeps the canonical one) and removed after 0.3.65.
|
|
112
|
+
* Callers log their own deprecation warning.
|
|
113
|
+
* @param config - the raw plugin config, carrying `root` and/or `skillsRoot`.
|
|
114
|
+
* @returns the effective root (empty when neither key is set) and whether the
|
|
115
|
+
* deprecated alias supplied it.
|
|
116
|
+
*/
|
|
117
|
+
export declare function resolveRootConfig(config?: {
|
|
118
|
+
root?: string | undefined;
|
|
119
|
+
skillsRoot?: string | undefined;
|
|
120
|
+
}): {
|
|
121
|
+
root: string;
|
|
122
|
+
usedDeprecatedAlias: boolean;
|
|
123
|
+
};
|
|
102
124
|
/**
|
|
103
125
|
* Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
|
|
104
126
|
* the APPROVAL surface treats every delegated subagent as the autonomous
|
|
@@ -228,13 +250,11 @@ export interface AuthoringFeedback {
|
|
|
228
250
|
* truncated or route-poor instead of silently shipping it.
|
|
229
251
|
*/
|
|
230
252
|
export declare function authoringFeedback(frontmatter: Frontmatter): AuthoringFeedback;
|
|
231
|
-
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
|
|
236
|
-
* consume this one set — a third copy would drift. */
|
|
237
|
-
export declare const WIN32_RESERVED_DEVICE_NAMES: ReadonlySet<string>;
|
|
253
|
+
/** A1-6 (v18): the regex alone admits `references/nul.md` (a Windows device
|
|
254
|
+
* stem), which the support-file layer refuses. Restructure must use the same
|
|
255
|
+
* rule, or it creates an orphan the later patch/write/remove paths refuse.
|
|
256
|
+
* Single source shared with the plan validator. */
|
|
257
|
+
export declare function validateRestructureTarget(filePath: string): string | null;
|
|
238
258
|
export declare class SkillLibrary {
|
|
239
259
|
readonly root: string;
|
|
240
260
|
readonly limits: SkillLimits;
|
|
@@ -340,6 +360,7 @@ export declare class SkillLibrary {
|
|
|
340
360
|
* marker write is the only state change; content is untouched.
|
|
341
361
|
*/
|
|
342
362
|
setPinned(name: string, pinned: boolean, origin?: WriteOrigin): Promise<SkillActionResult>;
|
|
363
|
+
private setPinnedCore;
|
|
343
364
|
create(name: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
|
|
344
365
|
private createCore;
|
|
345
366
|
update(rawName: string, content: string, origin?: WriteOrigin): Promise<SkillActionResult>;
|
|
@@ -378,15 +399,25 @@ export declare class SkillLibrary {
|
|
|
378
399
|
* or be swept as residue — the v16 first cut matched on suffix alone,
|
|
379
400
|
* which permanently refused archiving and deleted user content on restore. */
|
|
380
401
|
private isWriterLock;
|
|
381
|
-
/** P2 (v16): best-effort removal of lock residue inside a RESTORED tree
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
402
|
+
/** P2 (v16): best-effort removal of lock residue inside a RESTORED tree.
|
|
403
|
+
* A1-2/A1-7 (v18): the sweep now covers the marker locks the probe checks
|
|
404
|
+
* (`SKILL.md.lock`/`.pinned.lock`/`.hermes-managed.lock`) and only removes
|
|
405
|
+
* a lock whose holder pid is NOT alive — a live writer's lock is never
|
|
406
|
+
* stolen by the sweep. A dead-pid residue would otherwise permanently
|
|
407
|
+
* refuse archive/restore. */
|
|
386
408
|
private deleteStrandedLocks;
|
|
387
409
|
/** Remove `lockPath` only when its body has the writer-lock `pid:token`
|
|
388
|
-
* shape; anything else (a user support file
|
|
410
|
+
* shape AND the holder pid is not alive; anything else (a user support file
|
|
411
|
+
* or a live writer's lock) is left untouched. */
|
|
389
412
|
private sweepLockIfStranded;
|
|
413
|
+
/** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
|
|
414
|
+
* only a single, non-traversing path component is safe. Dotfiles
|
|
415
|
+
* (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed. */
|
|
416
|
+
private safeSnapshotEntryName;
|
|
417
|
+
/** A1-7 (v18): a root-level lock whose holder is alive must refuse the
|
|
418
|
+
* restore; a dead residue is swept so a crashed writer cannot block
|
|
419
|
+
* recovery. A non-lock body shape is left alone (user file). */
|
|
420
|
+
private refuseLiveLockOrSweep;
|
|
390
421
|
archive(rawName: string, options?: ArchiveOptions): Promise<SkillActionResult>;
|
|
391
422
|
/**
|
|
392
423
|
* Merge the bodies of `sources` into `target` and archive the sources with
|
package/lib/types/usage.d.ts
CHANGED
|
@@ -85,7 +85,7 @@ export declare function applyCuratorMetaFields(disk: UsageRecord, curated: Usage
|
|
|
85
85
|
* transitioned — a concurrent curator run's archive/restore is never reverted
|
|
86
86
|
* by a stale snapshot; without it both pairs apply everywhere.
|
|
87
87
|
*/
|
|
88
|
-
export declare function foldCuratorFields(disk: UsageMap, curated: UsageMap, stateOwned?: ReadonlySet<string>):
|
|
88
|
+
export declare function foldCuratorFields(disk: UsageMap, curated: UsageMap, stateOwned?: ReadonlySet<string>, runStartStates?: ReadonlyMap<string, string>): string[];
|
|
89
89
|
/** Whole-file usage write (V6-37, 0.3.37): this is the ONE path that bypasses
|
|
90
90
|
* the malformed-defense and the transact lock — prefer `mutateUsage` for any
|
|
91
91
|
* read-modify-write so a concurrent writer cannot lose its update and a
|
package/package.json
CHANGED