@lmzhen/dsh-evolution-core 0.3.67 → 0.3.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +766 -232
- package/lib/types/constants.d.ts +14 -0
- package/lib/types/curator.d.ts +21 -1
- package/lib/types/events.d.ts +10 -0
- package/lib/types/evolution-events.d.ts +9 -6
- package/lib/types/io.d.ts +65 -0
- package/lib/types/memory-store.d.ts +79 -13
- package/lib/types/prompts.d.ts +6 -6
- package/lib/types/skill-store.d.ts +101 -22
- package/lib/types/threats.d.ts +3 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -18,14 +18,24 @@ import { load } from "js-yaml";
|
|
|
18
18
|
* node backend's V5-03 short-circuit).
|
|
19
19
|
*/
|
|
20
20
|
async function transactIo(io, path, task) {
|
|
21
|
-
|
|
21
|
+
const committedOnly = (error) => isCommittedWarning(error);
|
|
22
|
+
if (io.transact) try {
|
|
22
23
|
await io.transact(path, task);
|
|
23
24
|
return;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (!committedOnly(error)) throw error;
|
|
27
|
+
console.warn(`evolution-io: ${path} was written but its directory fsync failed — the bytes are visible, durability is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
|
|
28
|
+
return;
|
|
24
29
|
}
|
|
25
30
|
const current = await io.readText(path);
|
|
26
31
|
const next = await task(current);
|
|
27
32
|
if (next === null) await io.remove(path);
|
|
28
|
-
else if (next !== current)
|
|
33
|
+
else if (next !== current) try {
|
|
34
|
+
await io.writeText(path, next);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (!committedOnly(error)) throw error;
|
|
37
|
+
console.warn(`evolution-io: ${path} was written but its directory fsync failed — the bytes are visible, durability is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
|
|
38
|
+
}
|
|
29
39
|
}
|
|
30
40
|
/** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
|
|
31
41
|
function evolutionIoAdapter(provider) {
|
|
@@ -214,6 +224,72 @@ function parseLockBody(body) {
|
|
|
214
224
|
return match === null ? null : Number(match[1]);
|
|
215
225
|
}
|
|
216
226
|
/**
|
|
227
|
+
* V27 G0.2 (EVO-IO-01): the write lock named by this claim is no longer ours
|
|
228
|
+
* at the commit point — a takeover reclaimed it while we were inside the
|
|
229
|
+
* critical section. The lock layer converts this into a retry of the whole
|
|
230
|
+
* read-modify-write; it must never reach a caller as a successful write.
|
|
231
|
+
*/
|
|
232
|
+
var LostWriteLock = class extends Error {
|
|
233
|
+
constructor() {
|
|
234
|
+
super("evolution-io: the write lock was reclaimed before the commit");
|
|
235
|
+
this.name = "LostWriteLock";
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
/** V27 G1.4: every quarantine-copy name this family has ever produced —
|
|
239
|
+
* `<file>.corrupt` (current fixed name), `<file>.corrupt.<epoch>`, and the
|
|
240
|
+
* v10-05 legacy `<file>.corrupt-<epoch>-<rand>` series. A user support file
|
|
241
|
+
* keeps a final extension (`.corrupt-backup.md`) and therefore stays. */
|
|
242
|
+
const CORRUPT_COPY_RE = /\.corrupt(\.\d+|-\d+-[0-9a-z]+)?$/;
|
|
243
|
+
/** V27 G1.1: the three takeover windows, as protocol constants at ONE place
|
|
244
|
+
* (the inline copies that used to live in the acquisition loop, plus the dead
|
|
245
|
+
* branch's bare `1000`, are gone). */
|
|
246
|
+
/** A named holder that is gone, past this age, is reclaimed. Fits inside the
|
|
247
|
+
* `lockAttempts * 50ms` retry budget so a dead holder's lock is reachable
|
|
248
|
+
* within one budget (v19 gate arithmetic: budget >= 2 x threshold). */
|
|
249
|
+
const DEAD_LOCK_TAKEOVER_MS = 1e3;
|
|
250
|
+
/** No body at all: nothing attributes the lock to a holder, so this is the one
|
|
251
|
+
* branch that cannot probe liveness — it must outlast any plausible stall
|
|
252
|
+
* between create and body write (V27 G0.2: 1s was below what a loaded machine
|
|
253
|
+
* actually took, which let a peer delete a live holder's lock). */
|
|
254
|
+
const EMPTY_LOCK_TAKEOVER_MS = 3e4;
|
|
255
|
+
/** A body with no parseable pid (crash mid-write): 1h, far above any legal hold
|
|
256
|
+
* and far below "forever". */
|
|
257
|
+
const LOCK_TEAR_TAKEOVER_MS = 36e5;
|
|
258
|
+
/**
|
|
259
|
+
* V27 G1.3: the error `commitTmp` throws when the rename landed but the parent
|
|
260
|
+
* directory fsync failed — the bytes ARE visible, only their durability is
|
|
261
|
+
* unconfirmed. A consumer that treats it as a plain failure reports "not
|
|
262
|
+
* written" for a write that happened (and a two-phase caller may try to roll
|
|
263
|
+
* back a visible write). Every transaction consumer must therefore treat this
|
|
264
|
+
* shape as SUCCESS-with-warning, never as a rejection.
|
|
265
|
+
*/
|
|
266
|
+
function isCommittedWarning(error) {
|
|
267
|
+
return error?.committed === true;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* V27 G1.1: the lock-takeover decision as ONE pure function, so the protocol is
|
|
271
|
+
* testable and exhaustive instead of being an inline expression inside the
|
|
272
|
+
* acquisition loop:
|
|
273
|
+
* - `none` the lock is fresh, or its holder is alive → wait, never steal;
|
|
274
|
+
* - `dead` a named holder that is gone, past the dead threshold;
|
|
275
|
+
* - `empty` no body at all: nothing attributes it to a holder, so only the
|
|
276
|
+
* wide `emptyAfterMs` window may reclaim it;
|
|
277
|
+
* - `corrupt` a body with no parseable pid (a crash mid-write), past the 1h
|
|
278
|
+
* tear threshold.
|
|
279
|
+
* The age thresholds compare against `nowMs - mtimeMs` with `>` so a lock whose
|
|
280
|
+
* age EQUALS the threshold is not yet reclaimed (the boundary the v19 gate fix
|
|
281
|
+
* pinned).
|
|
282
|
+
*/
|
|
283
|
+
function decideTakeover(probe) {
|
|
284
|
+
const age = (probe.nowMs ?? Date.now()) - probe.mtimeMs;
|
|
285
|
+
const holder = Number(probe.body.split(":")[0] ?? "");
|
|
286
|
+
const namedHolder = Number.isInteger(holder) && holder > 0;
|
|
287
|
+
if (probe.body === "") return age > (probe.emptyAfterMs ?? 3e4) ? "empty" : "none";
|
|
288
|
+
if (!namedHolder) return age > (probe.corruptAfterMs ?? 36e5) ? "corrupt" : "none";
|
|
289
|
+
if (probe.alive(holder)) return "none";
|
|
290
|
+
return age > (probe.deadAfterMs ?? 1e3) ? "dead" : "none";
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
217
293
|
* Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
|
|
218
294
|
* (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
|
|
219
295
|
* while contention TESTS on a loaded runner may raise it (e.g. 240 ≈ 12s) —
|
|
@@ -228,14 +304,6 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
228
304
|
/** True when the pid is alive (single source: `isProcessAlive`). */
|
|
229
305
|
const isAlive = isProcessAlive;
|
|
230
306
|
/**
|
|
231
|
-
* V10-07 (P2-1): age threshold for taking over a lock whose body is TORN
|
|
232
|
-
* (non-empty, but the pid prefix does not parse to a positive integer). 1h:
|
|
233
|
-
* a legal hold (the ~2s lock/rename retry budgets, a slow task) never
|
|
234
|
-
* approaches minutes, so 1h fires only in the "torn write + creator long
|
|
235
|
-
* dead" scenario — far above any legitimate hold, far below "forever".
|
|
236
|
-
*/
|
|
237
|
-
const LOCK_TEAR_TAKEOVER_MS = 36e5;
|
|
238
|
-
/**
|
|
239
307
|
* V10-06 (P1-1 integration fix): a takeover TICKET must never be reclaimed
|
|
240
308
|
* while its (live) holder can still be committing — the C-28 rename retry
|
|
241
309
|
* budget is ~2.35s under AV/indexer pressure, so the former 1s ticket age
|
|
@@ -280,31 +348,41 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
280
348
|
* parseable pid) — is taken over after a wide 1h threshold with a
|
|
281
349
|
* console.warn; before this branch such a lock blocked every future writer
|
|
282
350
|
* forever.
|
|
351
|
+
* V27 G0.2 (EVO-IO-01): the CONTRACT for `task` is therefore: commit only
|
|
352
|
+
* after calling the `assertOwned` callback it receives, immediately before the
|
|
353
|
+
* rename (or delete) that publishes the result. A takeover may reclaim a lock
|
|
354
|
+
* whose holder is alive — an empty body cannot be attributed to a pid, and a
|
|
355
|
+
* peer can read a stale stat — and the holder cannot see it from the handle it
|
|
356
|
+
* opened (on POSIX its write lands on the UNLINKED inode). With the guard, a
|
|
357
|
+
* reclaimed claim aborts the RMW (`LostWriteLock`, converted into a retry
|
|
358
|
+
* here) instead of overwriting the current holder's result; without it, two
|
|
359
|
+
* writers commit and one update is silently lost.
|
|
283
360
|
*/
|
|
284
361
|
const withWriteLock = async (path, task) => {
|
|
285
362
|
const lock = `${path}${LOCK_SUFFIX}`;
|
|
286
363
|
let myClaim = "";
|
|
287
364
|
for (let attempt = 0; attempt < lockAttempts; attempt += 1) {
|
|
288
|
-
let lockHandle = null;
|
|
289
365
|
try {
|
|
290
366
|
myClaim = `${process.pid}:${randomBytes(4).toString("hex")}`;
|
|
291
|
-
|
|
292
|
-
await lockHandle.writeFile(myClaim);
|
|
293
|
-
await lockHandle.close();
|
|
294
|
-
lockHandle = null;
|
|
367
|
+
await writeFile(lock, myClaim, { flag: "wx" });
|
|
295
368
|
} catch (error) {
|
|
296
369
|
const code = error?.code;
|
|
297
|
-
if (
|
|
298
|
-
await lockHandle.close().catch(() => {});
|
|
370
|
+
if (code === "EEXIST" || code === "EPERM") {} else {
|
|
299
371
|
await rm(lock, { force: true }).catch(() => {});
|
|
300
372
|
throw error;
|
|
301
373
|
}
|
|
302
|
-
if (code !== "EEXIST" && code !== "EPERM") throw error;
|
|
303
374
|
try {
|
|
304
375
|
const st = await stat(lock);
|
|
305
|
-
const
|
|
376
|
+
const holderRead = await readFile(lock, "utf8").then((body) => ({
|
|
377
|
+
ok: true,
|
|
378
|
+
body
|
|
379
|
+
}), () => ({
|
|
380
|
+
ok: false,
|
|
381
|
+
body: ""
|
|
382
|
+
}));
|
|
383
|
+
if (!holderRead.ok) continue;
|
|
384
|
+
const holderContent = holderRead.body;
|
|
306
385
|
const holder = Number(holderContent.split(":")[0] ?? "");
|
|
307
|
-
const holderAlive = Number.isInteger(holder) && holder > 0 && isAlive(holder);
|
|
308
386
|
if (holder === process.pid && pendingSelfCleanup.has(lock)) {
|
|
309
387
|
const token = pendingSelfCleanup.get(lock);
|
|
310
388
|
if (await readFile(lock, "utf8").catch(() => "") === token) try {
|
|
@@ -313,11 +391,13 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
313
391
|
} catch {}
|
|
314
392
|
continue;
|
|
315
393
|
}
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
394
|
+
const decision = decideTakeover({
|
|
395
|
+
body: holderContent,
|
|
396
|
+
mtimeMs: st.mtimeMs,
|
|
397
|
+
alive: isAlive
|
|
398
|
+
});
|
|
399
|
+
if (decision !== "none") {
|
|
400
|
+
console.warn(`evolution-io: taking over write lock ${lock} (branch=stale${decision[0]?.toUpperCase()}${decision.slice(1)}, body=${JSON.stringify(holderContent)}, ageMs=${Date.now() - st.mtimeMs}, holderPid=${Number.isInteger(holder) && holder > 0 ? holder : "none"})`);
|
|
321
401
|
if (await readFile(lock, "utf8").catch(() => "") === holderContent) {
|
|
322
402
|
const ticket = `${lock}.next`;
|
|
323
403
|
try {
|
|
@@ -357,8 +437,18 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
357
437
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
358
438
|
continue;
|
|
359
439
|
}
|
|
440
|
+
const assertOwned = async () => {
|
|
441
|
+
const body = await readFile(lock, "utf8").catch(() => null);
|
|
442
|
+
if (body !== myClaim) {
|
|
443
|
+
console.warn(`evolution-io: write lock ${lock} was reclaimed by another writer before the commit (on disk now: ${JSON.stringify(body)}, ours: ${JSON.stringify(myClaim)}) — aborting this read-modify-write and retrying under a fresh acquisition`);
|
|
444
|
+
throw new LostWriteLock();
|
|
445
|
+
}
|
|
446
|
+
};
|
|
360
447
|
try {
|
|
361
|
-
return await task();
|
|
448
|
+
return await task(assertOwned);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (error instanceof LostWriteLock) continue;
|
|
451
|
+
throw error;
|
|
362
452
|
} finally {
|
|
363
453
|
if (await readFile(lock, "utf8").catch(() => null) === myClaim) await rm(lock, {
|
|
364
454
|
force: true,
|
|
@@ -421,12 +511,12 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
421
511
|
const holder = Number(body.split(":")[0] ?? "");
|
|
422
512
|
const st = await stat(ticketPath);
|
|
423
513
|
const dead = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
|
|
424
|
-
const old = Date.now() - st.mtimeMs >
|
|
514
|
+
const old = Date.now() - st.mtimeMs > TICKET_STALE_MS;
|
|
425
515
|
if (dead || old) await rm(ticketPath, { force: true });
|
|
426
516
|
} catch {}
|
|
427
517
|
continue;
|
|
428
518
|
}
|
|
429
|
-
if (
|
|
519
|
+
if (CORRUPT_COPY_RE.test(name)) {
|
|
430
520
|
const corruptPath = join(dir, name);
|
|
431
521
|
try {
|
|
432
522
|
const st = await stat(corruptPath);
|
|
@@ -446,14 +536,16 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
446
536
|
},
|
|
447
537
|
async writeText(path, content) {
|
|
448
538
|
await mkdir(dirname(path), { recursive: true });
|
|
449
|
-
await withWriteLock(path, async () => {
|
|
539
|
+
await withWriteLock(path, async (assertOwned) => {
|
|
450
540
|
await sweepStaleTmps(path);
|
|
451
|
-
|
|
541
|
+
const tmp = await writeDurableTmp(path, content);
|
|
542
|
+
await assertOwned();
|
|
543
|
+
await commitTmp(tmp, path);
|
|
452
544
|
});
|
|
453
545
|
},
|
|
454
546
|
async transact(path, task) {
|
|
455
547
|
await mkdir(dirname(path), { recursive: true });
|
|
456
|
-
await withWriteLock(path, async () => {
|
|
548
|
+
await withWriteLock(path, async (assertOwned) => {
|
|
457
549
|
await sweepStaleTmps(path);
|
|
458
550
|
let current;
|
|
459
551
|
try {
|
|
@@ -464,11 +556,14 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
464
556
|
}
|
|
465
557
|
const next = await task(current);
|
|
466
558
|
if (next === null) {
|
|
559
|
+
await assertOwned();
|
|
467
560
|
await rm(path, { force: true });
|
|
468
561
|
return;
|
|
469
562
|
}
|
|
470
563
|
if (next === current) return;
|
|
471
|
-
|
|
564
|
+
const tmp = await writeDurableTmp(path, next);
|
|
565
|
+
await assertOwned();
|
|
566
|
+
await commitTmp(tmp, path);
|
|
472
567
|
});
|
|
473
568
|
},
|
|
474
569
|
async remove(path) {
|
|
@@ -578,6 +673,7 @@ function normalizeUsageRecord(record) {
|
|
|
578
673
|
const num = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
579
674
|
const bool = (value, fallback) => typeof value === "boolean" ? value : fallback;
|
|
580
675
|
return {
|
|
676
|
+
...raw,
|
|
581
677
|
created_by: typeof raw.created_by === "string" ? raw.created_by : null,
|
|
582
678
|
use_count: num(raw.use_count, base.use_count),
|
|
583
679
|
view_count: num(raw.view_count, base.view_count),
|
|
@@ -935,6 +1031,20 @@ const EVOLUTION_WRITE_TOOLS = ["memory", "skill_manage"];
|
|
|
935
1031
|
* 0.3.16 (T-4): moved here from skill-store.ts so drift-signals (pure, no IO)
|
|
936
1032
|
* can reference it without importing the skill-store module. */
|
|
937
1033
|
const AUTHORING_DESCRIPTION_BAR = 60;
|
|
1034
|
+
/** V27 G2.4: the largest millisecond delay a timer accepts. `AbortSignal.timeout`
|
|
1035
|
+
* (and `setTimeout`) coerce anything larger to 1ms after a Node warning, so a
|
|
1036
|
+
* timeout configured above this ceiling silently collapses to "immediately
|
|
1037
|
+
* aborted". The curator's review timeout and the review timeout each carried
|
|
1038
|
+
* their own copy of the literal; the bound is one protocol constant.
|
|
1039
|
+
* (v19 P2-10 corrected the value from 2^32-1 to Node's real 2^31-1 ceiling.) */
|
|
1040
|
+
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
1041
|
+
/** V27 G2.4: the model each review/curation leg defaults to. The policy schema,
|
|
1042
|
+
* the policy resolver and the curator's LLM nomination pass each carried their
|
|
1043
|
+
* own copy of these strings — a deployment that changed the policy default used
|
|
1044
|
+
* to leave the curator passing a different model than the reviews. */
|
|
1045
|
+
const DEFAULT_MEMORY_REVIEW_MODEL = "deepseek-v4-flash";
|
|
1046
|
+
const DEFAULT_SKILL_REVIEW_MODEL = "deepseek-v4-pro";
|
|
1047
|
+
const DEFAULT_CURATOR_MODEL = "deepseek-v4-pro";
|
|
938
1048
|
//#endregion
|
|
939
1049
|
//#region lib/types/gates.js
|
|
940
1050
|
/**
|
|
@@ -1002,6 +1112,8 @@ function buildCuratorRunReport(input) {
|
|
|
1002
1112
|
archiveCandidates: [...input.archiveCandidates],
|
|
1003
1113
|
archived: [...input.archived],
|
|
1004
1114
|
failed: [...input.failed],
|
|
1115
|
+
...input.aborted === void 0 ? {} : { aborted: input.aborted },
|
|
1116
|
+
...input.unattributed === void 0 || input.unattributed.length === 0 ? {} : { unattributed: [...input.unattributed] },
|
|
1005
1117
|
...input.consolidated === void 0 ? {} : { consolidated: [...input.consolidated] },
|
|
1006
1118
|
...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath },
|
|
1007
1119
|
...input.llmReviewEnabled === void 0 ? {} : { llmReviewEnabled: input.llmReviewEnabled },
|
|
@@ -1023,6 +1135,8 @@ function renderCuratorReportMarkdown(report) {
|
|
|
1023
1135
|
`- **LLM nominations**: ${report.llmNominations.length}`,
|
|
1024
1136
|
`- **Archived**: ${report.archived.length}`,
|
|
1025
1137
|
`- **Failed**: ${report.failed.length}`,
|
|
1138
|
+
...report.aborted === void 0 ? [] : [`- **Aborted**: ${report.aborted}`],
|
|
1139
|
+
...report.unattributed === void 0 || report.unattributed.length === 0 ? [] : [`- **Unattributed errors**: ${report.unattributed.length}`],
|
|
1026
1140
|
...report.snapshotPath === void 0 ? [] : [`- **Snapshot**: ${report.snapshotPath}`],
|
|
1027
1141
|
...report.llmReviewEnabled === void 0 ? [] : [`- **llmReview**: ${report.llmReviewEnabled}`],
|
|
1028
1142
|
...report.nominationsWarnings === void 0 || report.nominationsWarnings.length === 0 ? [] : [`- **Nomination warnings**: ${report.nominationsWarnings.join("; ")}`]
|
|
@@ -1037,6 +1151,7 @@ function renderCuratorReportMarkdown(report) {
|
|
|
1037
1151
|
...lines,
|
|
1038
1152
|
...section("Archived", report.archived.map((item) => `${item.name} (${item.reason})`)),
|
|
1039
1153
|
...section("Failed", report.failed.map((item) => `${item.name}: ${item.reason}`)),
|
|
1154
|
+
...section("Unattributed", report.unattributed ?? []),
|
|
1040
1155
|
...section("Stale candidates", report.staleCandidates),
|
|
1041
1156
|
...section("LLM nominations", report.llmNominations),
|
|
1042
1157
|
""
|
|
@@ -1195,15 +1310,6 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
1195
1310
|
});
|
|
1196
1311
|
result.markStale.push(name);
|
|
1197
1312
|
}
|
|
1198
|
-
} else if (idle < staleAfterDays) {
|
|
1199
|
-
record.state = "active";
|
|
1200
|
-
result.transitions.push({
|
|
1201
|
-
name,
|
|
1202
|
-
from: "stale",
|
|
1203
|
-
to: "active",
|
|
1204
|
-
reason: `recent activity ${Math.round(idle)}d`
|
|
1205
|
-
});
|
|
1206
|
-
result.reactivate.push(name);
|
|
1207
1313
|
} else if (idle >= config.archiveAfterDays) {
|
|
1208
1314
|
record.state = "archived";
|
|
1209
1315
|
record.archived_at = now.toISOString();
|
|
@@ -1214,6 +1320,15 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
1214
1320
|
reason: `idle ${Math.round(idle)}d >= ${config.archiveAfterDays}d`
|
|
1215
1321
|
});
|
|
1216
1322
|
result.archive.push(name);
|
|
1323
|
+
} else if (idle < staleAfterDays) {
|
|
1324
|
+
record.state = "active";
|
|
1325
|
+
result.transitions.push({
|
|
1326
|
+
name,
|
|
1327
|
+
from: "stale",
|
|
1328
|
+
to: "active",
|
|
1329
|
+
reason: `recent activity ${Math.round(idle)}d`
|
|
1330
|
+
});
|
|
1331
|
+
result.reactivate.push(name);
|
|
1217
1332
|
}
|
|
1218
1333
|
}
|
|
1219
1334
|
return result;
|
|
@@ -1230,12 +1345,15 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
1230
1345
|
*
|
|
1231
1346
|
* Usage events (C semantics, rc.73+): `type:'usage'` records are the
|
|
1232
1347
|
* OBSERVATION WINDOW ANCHOR — written once, when the library's first observed
|
|
1233
|
-
* read (`view_count` 0 -> 1) happens.
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1238
|
-
* and `window.opened` pins
|
|
1348
|
+
* read (`view_count` 0 -> 1) happens. The anchor is the durable timeline record
|
|
1349
|
+
* of that moment: the churn-suppression gate itself (`usageObserved()`) reads
|
|
1350
|
+
* the usage SIDECAR's own first-view evidence, since reads were invisible to it
|
|
1351
|
+
* pre-A2 and no sidecar record can reach `view_count > 0` without the same
|
|
1352
|
+
* 0 -> 1 transition. `counts` on the event is a cumulative library-wide
|
|
1353
|
+
* snapshot (skills/views/use/patches) at that moment, and `window.opened` pins
|
|
1354
|
+
* the window start for the timeline. (V27 G3.3: `verify-event-pairing` requires
|
|
1355
|
+
* every persisted type to have a production reader or a declared external
|
|
1356
|
+
* contract — this one is the latter.)
|
|
1239
1357
|
*
|
|
1240
1358
|
* Rotation (rc.71, 007 design): when the active log reaches
|
|
1241
1359
|
* `EVENT_LOG_ROTATE_AT` the older half is split into an archive
|
|
@@ -1411,9 +1529,28 @@ async function rotateIfDue(io, path, events, rotateAt) {
|
|
|
1411
1529
|
if (tail.length === 0) return events;
|
|
1412
1530
|
const anchor = tail[0]?.seq ?? 0;
|
|
1413
1531
|
const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
|
|
1532
|
+
let archived = head;
|
|
1533
|
+
const existing = await io.readText(archivePath).catch(() => null);
|
|
1534
|
+
if (existing !== null) try {
|
|
1535
|
+
const parsed = JSON.parse(existing);
|
|
1536
|
+
const usablePrior = (Array.isArray(parsed.events) ? parsed.events : []).filter(isEventRecord);
|
|
1537
|
+
const bySeq = new Map(usablePrior.map((event) => [event.seq, event]));
|
|
1538
|
+
for (const event of head) bySeq.set(event.seq, event);
|
|
1539
|
+
archived = [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
|
1540
|
+
} catch {
|
|
1541
|
+
const shiftPath = `${archivePath}.${Date.now()}.collide`;
|
|
1542
|
+
await io.writeText(shiftPath, JSON.stringify({
|
|
1543
|
+
version: 1,
|
|
1544
|
+
events: head
|
|
1545
|
+
}, null, 2));
|
|
1546
|
+
console.warn(`evolution-events: rotation hit an unparsable archive collision — the rotated head band was preserved at ${shiftPath} but is OUTSIDE the logical timeline; inspect and merge it manually`);
|
|
1547
|
+
await retainEventArchives(io, path);
|
|
1548
|
+
await pruneCollideArchives(io, path);
|
|
1549
|
+
return tail;
|
|
1550
|
+
}
|
|
1414
1551
|
await io.writeText(archivePath, JSON.stringify({
|
|
1415
1552
|
version: 1,
|
|
1416
|
-
events:
|
|
1553
|
+
events: archived
|
|
1417
1554
|
}, null, 2));
|
|
1418
1555
|
await retainEventArchives(io, path);
|
|
1419
1556
|
return tail;
|
|
@@ -1431,6 +1568,34 @@ async function retainEventArchives(io, path) {
|
|
|
1431
1568
|
const excess = names.slice(0, Math.max(0, names.length - 10));
|
|
1432
1569
|
for (const name of excess) await io.remove(join(dir, name)).catch(() => {});
|
|
1433
1570
|
}
|
|
1571
|
+
/** v31 EVENTS-02: prune shift-aside collision files (`events-*.json.<ts>.collide`)
|
|
1572
|
+
* after the same 7-day window the `.corrupt` sweep uses. They are write-once
|
|
1573
|
+
* recovery artifacts no reader accepts; without a sweep they accumulated
|
|
1574
|
+
* without bound across rollback episodes. No mtime probe → keep (fail-safe). */
|
|
1575
|
+
const COLLIDE_AGE_MS = 10080 * 60 * 1e3;
|
|
1576
|
+
async function pruneCollideArchives(io, path) {
|
|
1577
|
+
const dir = dirname(path);
|
|
1578
|
+
let names;
|
|
1579
|
+
try {
|
|
1580
|
+
names = await io.list(dir);
|
|
1581
|
+
} catch {
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
const now = Date.now();
|
|
1585
|
+
for (const name of names) {
|
|
1586
|
+
if (!name.endsWith(".collide") || !name.startsWith("events-")) continue;
|
|
1587
|
+
const full = join(dir, name);
|
|
1588
|
+
const stamp = name.match(/\.(\d{13})\.collide$/);
|
|
1589
|
+
if (stamp && now - Number(stamp[1]) < COLLIDE_AGE_MS) continue;
|
|
1590
|
+
if (!stamp) try {
|
|
1591
|
+
const mtime = await io.mtime?.(full);
|
|
1592
|
+
if (typeof mtime === "number" && now - mtime < COLLIDE_AGE_MS) continue;
|
|
1593
|
+
} catch {
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
await io.remove(full).catch(() => {});
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1434
1599
|
/** Read the event log; a missing/whitespace-only file reads as empty,
|
|
1435
1600
|
* corrupt content is flagged (and refused on append). A well-formed future-
|
|
1436
1601
|
* version body is v1-incompatible and reads as empty, NOT malformed (F-338:
|
|
@@ -1537,16 +1702,21 @@ function allowRowCollisions(env = process.env) {
|
|
|
1537
1702
|
* platform's index cap), and DSH-only additions are marked as such.
|
|
1538
1703
|
*
|
|
1539
1704
|
* Every prompt is pinned in a versioned bundle. Review workers verify the
|
|
1540
|
-
* bundle digest before spending a model call
|
|
1541
|
-
*
|
|
1705
|
+
* bundle digest before spending a model call — v31 PROMPT-01, stated
|
|
1706
|
+
* precisely: THAT check proves internal coherence (id/version/digest agree)
|
|
1707
|
+
* for a bundle assembled OUT of process and handed to `verifyPromptBundle`
|
|
1708
|
+
* explicitly. It CANNOT detect in-process tampering (the digest is recomputed
|
|
1709
|
+
* from the same module state it verifies) — catching a stale or partially
|
|
1710
|
+
* patched default bundle is CI's version pin (tests/prompts.spec.ts), not
|
|
1711
|
+
* this runtime gate.
|
|
1542
1712
|
*/
|
|
1543
1713
|
/**
|
|
1544
1714
|
* Prompt bundle identity. Bump both id and version whenever a prompt's text
|
|
1545
1715
|
* changes semantically: the bundle digest is the fail-closed signal for
|
|
1546
1716
|
* review workers, so a stale id across deployments must be distinguishable.
|
|
1547
1717
|
*/
|
|
1548
|
-
const PROMPT_BUNDLE_VERSION =
|
|
1549
|
-
const PROMPT_BUNDLE_ID = `dsh-evolution@
|
|
1718
|
+
const PROMPT_BUNDLE_VERSION = 17;
|
|
1719
|
+
const PROMPT_BUNDLE_ID = `dsh-evolution@17`;
|
|
1550
1720
|
const MEMORY_REVIEW_PROMPT = `[Auto-review — Memory]
|
|
1551
1721
|
Review the conversation above and consider saving to memory if appropriate.
|
|
1552
1722
|
|
|
@@ -1567,7 +1737,7 @@ Signals to look for (any one of these warrants action):
|
|
|
1567
1737
|
• Non-trivial technique, fix, workaround, debugging path, or tool-usage pattern emerged that a future session would benefit from. Capture it.
|
|
1568
1738
|
• A skill that got loaded or consulted this session turned out to be wrong, missing a step, or outdated. Patch it NOW.
|
|
1569
1739
|
|
|
1570
|
-
Read-before-write
|
|
1740
|
+
Read-before-write: update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session. On the plan channel ops on unread skills are rejected; direct writes have no such guard, so treat the rule as binding. CREATE of a brand-new umbrella is the only exception.
|
|
1571
1741
|
|
|
1572
1742
|
Preference order — prefer the earliest action that fits, but do pick one when a signal above fired:
|
|
1573
1743
|
1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the conversation for skills the user loaded or you read. If any of them covers the territory of the new learning, PATCH that one first. It is the skill that was in play, so it's the right one to extend.
|
|
@@ -1617,7 +1787,7 @@ Signals that warrant a skill update (any one is enough):
|
|
|
1617
1787
|
• Non-trivial technique, fix, workaround, or debugging path emerged.
|
|
1618
1788
|
• A skill that was loaded or consulted turned out wrong, missing, or outdated — patch it now.
|
|
1619
1789
|
|
|
1620
|
-
Read-before-write
|
|
1790
|
+
Read-before-write: update, patch, delete, or write support files ONLY into skills you loaded or read in THIS session. On the plan channel ops on unread skills are rejected; direct writes have no such guard, so treat the rule as binding. CREATE of a brand-new umbrella is the only exception.
|
|
1621
1791
|
|
|
1622
1792
|
Preference order for skills — pick the earliest that fits:
|
|
1623
1793
|
1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were loaded or read in the conversation. If one of them covers the learning, PATCH it first. It was in play; it's the right place.
|
|
@@ -1833,12 +2003,12 @@ function sha256(text) {
|
|
|
1833
2003
|
function createPromptBundle(prompts) {
|
|
1834
2004
|
const canonical = JSON.stringify({
|
|
1835
2005
|
id: PROMPT_BUNDLE_ID,
|
|
1836
|
-
version:
|
|
2006
|
+
version: 17,
|
|
1837
2007
|
prompts: Object.fromEntries(Object.entries(prompts).sort())
|
|
1838
2008
|
});
|
|
1839
2009
|
return Object.freeze({
|
|
1840
2010
|
id: PROMPT_BUNDLE_ID,
|
|
1841
|
-
version:
|
|
2011
|
+
version: 17,
|
|
1842
2012
|
prompts: Object.freeze({ ...prompts }),
|
|
1843
2013
|
sha256: sha256(canonical)
|
|
1844
2014
|
});
|
|
@@ -1856,10 +2026,10 @@ const PROMPT_BUNDLE = createPromptBundle({
|
|
|
1856
2026
|
skillsGuidance: SKILLS_GUIDANCE
|
|
1857
2027
|
});
|
|
1858
2028
|
function verifyPromptBundle(bundle = PROMPT_BUNDLE) {
|
|
1859
|
-
if (bundle.id !== PROMPT_BUNDLE_ID || bundle.version !==
|
|
2029
|
+
if (bundle.id !== PROMPT_BUNDLE_ID || bundle.version !== 17) return false;
|
|
1860
2030
|
const canonical = JSON.stringify({
|
|
1861
2031
|
id: PROMPT_BUNDLE_ID,
|
|
1862
|
-
version:
|
|
2032
|
+
version: 17,
|
|
1863
2033
|
prompts: Object.fromEntries(Object.entries(bundle.prompts).sort())
|
|
1864
2034
|
});
|
|
1865
2035
|
return bundle.sha256 === sha256(canonical);
|
|
@@ -2248,7 +2418,8 @@ const PATTERNS = [
|
|
|
2248
2418
|
regex: /(?:密钥|凭据|口令|密码|环境变量)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至)\s*(?:https?:\/\/|[\w.-]+\.(?:com|net|org|io|cn|dev|xyz|ru)\b)/
|
|
2249
2419
|
}
|
|
2250
2420
|
];
|
|
2251
|
-
const
|
|
2421
|
+
const INVISIBLE_CHAR_CLASS = "\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}";
|
|
2422
|
+
const ZERO_WIDTH_CHARS = new RegExp(`[${INVISIBLE_CHAR_CLASS}]`, "u");
|
|
2252
2423
|
const ZWJ_OUTSIDE_EMOJI = /(?<!\p{Extended_Pictographic})\u200d(?!\p{Extended_Pictographic})/u;
|
|
2253
2424
|
const TYPOGRAPHY_CHARS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/;
|
|
2254
2425
|
const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
|
|
@@ -2295,7 +2466,8 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
|
|
|
2295
2466
|
});
|
|
2296
2467
|
const normalized = text.normalize("NFKC");
|
|
2297
2468
|
const SPACE_SPLITTERS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/gu;
|
|
2298
|
-
const
|
|
2469
|
+
const OBFUSCATION_SPLITTERS = new RegExp(`[${INVISIBLE_CHAR_CLASS}\\u200d]`, "gu");
|
|
2470
|
+
const patternTexts = [normalized.replace(SPACE_SPLITTERS, " ").replace(OBFUSCATION_SPLITTERS, " "), normalized.replace(SPACE_SPLITTERS, "").replace(OBFUSCATION_SPLITTERS, "")];
|
|
2299
2471
|
const windows = [];
|
|
2300
2472
|
for (const patternText of patternTexts) if (patternText.length <= windowSize) windows.push(patternText);
|
|
2301
2473
|
else {
|
|
@@ -2563,6 +2735,53 @@ var MemoryStore = class {
|
|
|
2563
2735
|
limit: this.limitFor(target)
|
|
2564
2736
|
};
|
|
2565
2737
|
}
|
|
2738
|
+
/**
|
|
2739
|
+
* The ONE memory write skeleton (V27 G2.5): oversized read-guard pre-transact,
|
|
2740
|
+
* one transaction over the target path, and the C-01 structured refusal when a
|
|
2741
|
+
* backend never invokes the task. `addChained` and `applyBatchChained` supply
|
|
2742
|
+
* only their own in-transaction core, so the two write paths cannot drift in
|
|
2743
|
+
* their guard order, their missing-file handling or their error text.
|
|
2744
|
+
*
|
|
2745
|
+
* @param target - memory target being written
|
|
2746
|
+
* @param core - the in-transaction read-modify-write for the locked body
|
|
2747
|
+
* @param shrinkOnly - v29 MEM-02: every queued operation REMOVES an entry
|
|
2748
|
+
* (the recovery path for a limit lowered under existing content). The
|
|
2749
|
+
* oversized read-guard is skipped for these batches: a canonical file
|
|
2750
|
+
* written under a ≥10× higher limit trips the `limit × 10` byte bound, and
|
|
2751
|
+
* the guard's "fix the file manually" refusal would pre-empt exactly the
|
|
2752
|
+
* shrink recovery MEM-01 advertises. The load is bounded by what the store
|
|
2753
|
+
* itself wrote under the old limit.
|
|
2754
|
+
* @returns the core's result, or the oversized / contract-violation refusal
|
|
2755
|
+
*/
|
|
2756
|
+
async chainedWrite(target, core, shrinkOnly = false) {
|
|
2757
|
+
const SHRINK_LOAD_CEILING = 64 * 1024 * 1024;
|
|
2758
|
+
if (!shrinkOnly) {
|
|
2759
|
+
const refusal = await this.oversizedRefusal(target);
|
|
2760
|
+
if (refusal) return refusal;
|
|
2761
|
+
} else {
|
|
2762
|
+
const size = await this.io.size?.(fileFor(this.root, target));
|
|
2763
|
+
if (typeof size === "number" && size > SHRINK_LOAD_CEILING) return {
|
|
2764
|
+
ok: false,
|
|
2765
|
+
message: `Memory file is ${size} bytes - too large to load even for a remove-only batch. Fix the file manually, then retry.`,
|
|
2766
|
+
entries: [],
|
|
2767
|
+
chars: 0,
|
|
2768
|
+
limit: this.limitFor(target)
|
|
2769
|
+
};
|
|
2770
|
+
}
|
|
2771
|
+
let outcome;
|
|
2772
|
+
await transactIo(this.io, fileFor(this.root, target), async (current) => {
|
|
2773
|
+
const step = await core(current ?? "");
|
|
2774
|
+
outcome = step.result;
|
|
2775
|
+
return step.write ?? current ?? null;
|
|
2776
|
+
});
|
|
2777
|
+
return outcome ?? {
|
|
2778
|
+
ok: false,
|
|
2779
|
+
message: "internal error: the memory transaction did not invoke the task; no write was performed",
|
|
2780
|
+
entries: [],
|
|
2781
|
+
chars: 0,
|
|
2782
|
+
limit: this.limitFor(target)
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2566
2785
|
async add(target, facts) {
|
|
2567
2786
|
return await this.serial(() => this.addChained(target, facts));
|
|
2568
2787
|
}
|
|
@@ -2574,22 +2793,7 @@ var MemoryStore = class {
|
|
|
2574
2793
|
chars: 0,
|
|
2575
2794
|
limit: this.limitFor(target)
|
|
2576
2795
|
};
|
|
2577
|
-
|
|
2578
|
-
const refusal = await this.oversizedRefusal(target);
|
|
2579
|
-
if (refusal) return refusal;
|
|
2580
|
-
let outcome;
|
|
2581
|
-
await transactIo(this.io, path, async (current) => {
|
|
2582
|
-
const core = await this.addCore(target, facts, current ?? "");
|
|
2583
|
-
outcome = core.result;
|
|
2584
|
-
return core.write ?? current ?? null;
|
|
2585
|
-
});
|
|
2586
|
-
return outcome ?? {
|
|
2587
|
-
ok: false,
|
|
2588
|
-
message: "internal error: the memory transaction did not invoke the task; no write was performed",
|
|
2589
|
-
entries: [],
|
|
2590
|
-
chars: 0,
|
|
2591
|
-
limit: this.limitFor(target)
|
|
2592
|
-
};
|
|
2796
|
+
return await this.chainedWrite(target, async (raw) => await this.addCore(target, facts, raw));
|
|
2593
2797
|
}
|
|
2594
2798
|
/**
|
|
2595
2799
|
* Single-entry add inside the transaction: shared checks (oversized,
|
|
@@ -2608,19 +2812,11 @@ var MemoryStore = class {
|
|
|
2608
2812
|
},
|
|
2609
2813
|
write: null
|
|
2610
2814
|
};
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
2617
|
-
entries: [],
|
|
2618
|
-
chars: 0,
|
|
2619
|
-
limit: this.limitFor(target)
|
|
2620
|
-
},
|
|
2621
|
-
write: null
|
|
2622
|
-
};
|
|
2623
|
-
}
|
|
2815
|
+
const refusal = await this.driftRefusal(target, raw);
|
|
2816
|
+
if (refusal) return {
|
|
2817
|
+
result: refusal,
|
|
2818
|
+
write: null
|
|
2819
|
+
};
|
|
2624
2820
|
const threat = this.memoryThreatBlock(content);
|
|
2625
2821
|
if (threat) return {
|
|
2626
2822
|
result: {
|
|
@@ -2676,57 +2872,114 @@ var MemoryStore = class {
|
|
|
2676
2872
|
write: render(next)
|
|
2677
2873
|
};
|
|
2678
2874
|
}
|
|
2679
|
-
/**
|
|
2680
|
-
|
|
2681
|
-
|
|
2875
|
+
/**
|
|
2876
|
+
* The single drift evaluation. `raw` is in canonical form when it byte-matches
|
|
2877
|
+
* `render(normalizeEntries(raw))`; anything else means it was edited outside
|
|
2878
|
+
* MemoryStore (empty/`§`-only entries, stray blank lines, leading or trailing
|
|
2879
|
+
* delimiters — structural anomalies the writer would quietly normalize away).
|
|
2880
|
+
* Both write paths derive this from their locked view and `detectDrift` from
|
|
2881
|
+
* a fresh read, so a write and a later read can never disagree about the same
|
|
2882
|
+
* bytes.
|
|
2883
|
+
*
|
|
2884
|
+
* An absent, empty, or whitespace-only body is the "never written" state
|
|
2885
|
+
* (rc.42 audit P1-6): it parses to zero entries, and the canonical form
|
|
2886
|
+
* `'\n'` can never byte-match it, so flagging it would permanently refuse
|
|
2887
|
+
* every write path — including the repairs the model would need to make.
|
|
2888
|
+
* Such files are adopted instead of flagged.
|
|
2889
|
+
*
|
|
2890
|
+
* Returns one of:
|
|
2891
|
+
* - `'external'` — non-canonical body, i.e. real external modification. This
|
|
2892
|
+
* includes the Hermes-parity signal #2 (an entry larger than the whole-file
|
|
2893
|
+
* limit): that shape is only meaningful as external evidence on a
|
|
2894
|
+
* NON-canonical body, because free-form external appends never render
|
|
2895
|
+
* canonically.
|
|
2896
|
+
* - `'over-limit'` — v28 MEM-01: a CANONICAL body whose entries exceed the
|
|
2897
|
+
* CURRENT configured limit. Those bytes were written by this store under a
|
|
2898
|
+
* previous (higher) limit, so they are not external drift; treating them as
|
|
2899
|
+
* such misattributed a config change to an "external editor" and bricked
|
|
2900
|
+
* every write path (the advertised recovery — remove/consolidate — is
|
|
2901
|
+
* exactly what the drift gate refused). Callers route this state to a
|
|
2902
|
+
* config-naming refusal and let shrink-only batches through.
|
|
2903
|
+
* - `null` — no drift: writable as-is.
|
|
2904
|
+
*
|
|
2905
|
+
* @param target - memory target whose char limit bounds one parsed entry
|
|
2906
|
+
* @param raw - on-disk body, or `null` when the file does not exist
|
|
2907
|
+
*/
|
|
2908
|
+
driftKind(target, raw) {
|
|
2909
|
+
if (raw === null || raw.trim() === "") return null;
|
|
2682
2910
|
const entries = normalizeEntries(raw);
|
|
2911
|
+
if (render(entries) !== raw) return "external";
|
|
2683
2912
|
const limit = this.limitFor(target);
|
|
2684
|
-
if (limit > 0 && entries.some((entry) => entry.length > limit)) return
|
|
2685
|
-
return
|
|
2913
|
+
if (limit > 0 && entries.some((entry) => entry.length > limit)) return "over-limit";
|
|
2914
|
+
return null;
|
|
2686
2915
|
}
|
|
2687
|
-
|
|
2688
|
-
|
|
2916
|
+
/** External-drift predicate: canonical-form violations only (see
|
|
2917
|
+
* {@link driftKind}). `detectDrift` and the write paths share it, so a write
|
|
2918
|
+
* and a later read never disagree about the same bytes. */
|
|
2919
|
+
drifted(target, raw) {
|
|
2920
|
+
return this.driftKind(target, raw) === "external";
|
|
2689
2921
|
}
|
|
2690
|
-
|
|
2691
|
-
|
|
2922
|
+
/**
|
|
2923
|
+
* Drift refusal for a body already read under the write lock, or `null` when
|
|
2924
|
+
* writing may proceed. Both write paths return this unchanged, so their
|
|
2925
|
+
* refusals stay byte-identical and each carries the same backup.
|
|
2926
|
+
*
|
|
2927
|
+
* The `'over-limit'` state never refuses shrink-only batches (`shrinkOnly`):
|
|
2928
|
+
* removing entries is the advertised recovery for a limit lowered under
|
|
2929
|
+
* existing content, and the batch's own final limit check still gates the
|
|
2930
|
+
* result.
|
|
2931
|
+
*
|
|
2932
|
+
* @param target - memory target that owns the drifted file
|
|
2933
|
+
* @param raw - locked file body
|
|
2934
|
+
* @param shrinkOnly - every queued operation removes an entry (no growth)
|
|
2935
|
+
* @returns the refusal to hand back, or `null` to continue writing
|
|
2936
|
+
*/
|
|
2937
|
+
async driftRefusal(target, raw, shrinkOnly = false) {
|
|
2938
|
+
const kind = this.driftKind(target, raw);
|
|
2939
|
+
if (kind === null) return null;
|
|
2940
|
+
if (kind === "over-limit") {
|
|
2941
|
+
if (shrinkOnly) return null;
|
|
2942
|
+
const limit = this.limitFor(target);
|
|
2943
|
+
const entries = normalizeEntries(raw);
|
|
2944
|
+
const over = entries.filter((entry) => entry.length > limit).length;
|
|
2945
|
+
return {
|
|
2946
|
+
ok: false,
|
|
2947
|
+
message: `${over} memory ${over === 1 ? "entry exceeds" : "entries exceed"} the configured ${target}CharLimit (${limit}); they were written under a higher limit. Raise the limit or remove entries — remove operations stay available.`,
|
|
2948
|
+
entries,
|
|
2949
|
+
chars: entries.join(ENTRY_DELIMITER).length,
|
|
2950
|
+
limit
|
|
2951
|
+
};
|
|
2952
|
+
}
|
|
2953
|
+
const backup = await this.backupFile(target);
|
|
2954
|
+
return {
|
|
2692
2955
|
ok: false,
|
|
2693
|
-
message: "
|
|
2956
|
+
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
2694
2957
|
entries: [],
|
|
2695
2958
|
chars: 0,
|
|
2696
2959
|
limit: this.limitFor(target)
|
|
2697
2960
|
};
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
outcome = core.result;
|
|
2705
|
-
return core.write ?? current ?? null;
|
|
2706
|
-
});
|
|
2707
|
-
return outcome ?? {
|
|
2961
|
+
}
|
|
2962
|
+
async applyBatch(target, operations) {
|
|
2963
|
+
return await this.serial(() => this.applyBatchChained(target, operations));
|
|
2964
|
+
}
|
|
2965
|
+
async applyBatchChained(target, operations) {
|
|
2966
|
+
if (operations.length === 0) return {
|
|
2708
2967
|
ok: false,
|
|
2709
|
-
message: "
|
|
2968
|
+
message: "operations list is empty.",
|
|
2710
2969
|
entries: [],
|
|
2711
2970
|
chars: 0,
|
|
2712
2971
|
limit: this.limitFor(target)
|
|
2713
2972
|
};
|
|
2973
|
+
const shrinkOnly = operations.every((op) => op.action === "remove");
|
|
2974
|
+
return await this.chainedWrite(target, async (raw) => await this.applyBatchCore(target, operations, raw, shrinkOnly), shrinkOnly);
|
|
2714
2975
|
}
|
|
2715
2976
|
/** Batch RMW inside the transaction. `write: null` = failure/no-op, disk untouched. */
|
|
2716
|
-
async applyBatchCore(target, operations, raw) {
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
2723
|
-
entries: [],
|
|
2724
|
-
chars: 0,
|
|
2725
|
-
limit: this.limitFor(target)
|
|
2726
|
-
},
|
|
2727
|
-
write: null
|
|
2728
|
-
};
|
|
2729
|
-
}
|
|
2977
|
+
async applyBatchCore(target, operations, raw, shrinkOnly = false) {
|
|
2978
|
+
const refusal = await this.driftRefusal(target, raw, shrinkOnly);
|
|
2979
|
+
if (refusal) return {
|
|
2980
|
+
result: refusal,
|
|
2981
|
+
write: null
|
|
2982
|
+
};
|
|
2730
2983
|
const entries = [...new Set(normalizeEntries(raw))];
|
|
2731
2984
|
const working = [...entries];
|
|
2732
2985
|
const datePrefix = this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n` : "";
|
|
@@ -2896,27 +3149,17 @@ var MemoryStore = class {
|
|
|
2896
3149
|
return parts.join("\n\n");
|
|
2897
3150
|
}
|
|
2898
3151
|
/**
|
|
2899
|
-
* Detect on-disk drift
|
|
2900
|
-
*
|
|
2901
|
-
*
|
|
2902
|
-
*
|
|
2903
|
-
* edited outside MemoryStore. Purely single-canonical content reaches the
|
|
2904
|
-
* same serialization and returns false, so a normal write is never flagged.
|
|
3152
|
+
* Detect on-disk drift for a caller that holds no locked view: `true` when the
|
|
3153
|
+
* file is not in canonical form, or when its size trips the read guard. The
|
|
3154
|
+
* write paths apply the same predicate (`drifted`) to the body they read under
|
|
3155
|
+
* the lock, so a write and a follow-up read agree about the same bytes.
|
|
2905
3156
|
*
|
|
2906
|
-
*
|
|
2907
|
-
*
|
|
2908
|
-
* `'\n'` can never byte-match it and every write path was permanently
|
|
2909
|
-
* refused with "External drift detected" — including the repairs the model
|
|
2910
|
-
* would need to make. Such files are adopted instead of flagged.
|
|
3157
|
+
* @param target - memory target to inspect
|
|
3158
|
+
* @returns whether the file on disk counts as externally drifted
|
|
2911
3159
|
*/
|
|
2912
3160
|
async detectDrift(target) {
|
|
2913
3161
|
if (await this.oversizedFile(target)) return true;
|
|
2914
|
-
|
|
2915
|
-
if (raw === null || raw.trim() === "") return false;
|
|
2916
|
-
const entries = normalizeEntries(raw);
|
|
2917
|
-
const limit = this.limitFor(target);
|
|
2918
|
-
if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
|
|
2919
|
-
return render(entries) !== raw;
|
|
3162
|
+
return this.drifted(target, await this.io.readText(fileFor(this.root, target)));
|
|
2920
3163
|
}
|
|
2921
3164
|
};
|
|
2922
3165
|
//#endregion
|
|
@@ -3258,8 +3501,10 @@ const SECRET_PATTERNS = [
|
|
|
3258
3501
|
["google api key", /AIza[0-9A-Za-z_-]{30,}/g],
|
|
3259
3502
|
["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
|
|
3260
3503
|
];
|
|
3261
|
-
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");
|
|
3504
|
+
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");
|
|
3262
3505
|
const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)@/gi;
|
|
3506
|
+
const PEM_PRIVATE_KEY_PATTERN = new RegExp(`-----BEGIN\\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\\s+)?PRIVATE\\s+KEY(?:\\s+BLOCK)?-----[\\s\\S]*?-----END\\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\\s+)?PRIVATE\\s+KEY(?:\\s+BLOCK)?-----`, "g");
|
|
3507
|
+
const BLOCK_KEY_ONLY_LINE = /(?:^|\s)([\w-]{0,64}[_\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\-][\w-]{0,64})?)\s*:\s*$/i;
|
|
3263
3508
|
/**
|
|
3264
3509
|
* Mask credential-shaped text before it crosses a session boundary.
|
|
3265
3510
|
* @param text - the text about to be sent to a model outside this session.
|
|
@@ -3267,9 +3512,21 @@ const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)@/g
|
|
|
3267
3512
|
*/
|
|
3268
3513
|
function redactSecrets(text) {
|
|
3269
3514
|
let out = text;
|
|
3515
|
+
out = out.replace(PEM_PRIVATE_KEY_PATTERN, "<redacted-private-key>");
|
|
3270
3516
|
for (const [, pattern] of SECRET_PATTERNS) out = out.replace(pattern, "<redacted>");
|
|
3271
3517
|
out = out.replace(URL_CREDENTIALS_PATTERN, (_match, lead) => `${lead ?? ""}<redacted>@`);
|
|
3272
3518
|
out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, lead, prefix, key, separator) => `${lead ?? ""}${prefix ?? ""}${key ?? ""}${separator ?? ""}<redacted>`);
|
|
3519
|
+
const lines = out.split("\n");
|
|
3520
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
3521
|
+
const line = lines[i];
|
|
3522
|
+
if (line === void 0 || !BLOCK_KEY_ONLY_LINE.test(line)) continue;
|
|
3523
|
+
const next = lines[i + 1] ?? "";
|
|
3524
|
+
const [, indent, value, tail] = /^([ \t]+)(\S.*?)([ \t]*)$/.exec(next) ?? [];
|
|
3525
|
+
if (indent === void 0 || value === void 0) continue;
|
|
3526
|
+
if (value.includes("<redacted>")) continue;
|
|
3527
|
+
lines[i + 1] = `${indent}<redacted>${tail ?? ""}`;
|
|
3528
|
+
}
|
|
3529
|
+
out = lines.join("\n");
|
|
3273
3530
|
out = out.split("\n").map((line) => {
|
|
3274
3531
|
if (!/<redacted>/.test(line) && !/\baws\b|\bAKIA\b|\bsecret\b/i.test(line)) return line;
|
|
3275
3532
|
return line.replace(/(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g, "<redacted>");
|
|
@@ -3654,28 +3911,28 @@ function skillsRoot(env = process.env) {
|
|
|
3654
3911
|
function resolveSkillsRoot(config = {}) {
|
|
3655
3912
|
return (config.root ?? "").trim() || skillsRoot();
|
|
3656
3913
|
}
|
|
3657
|
-
/** E-7 (v18): every family row reads ONE root key. `root` is
|
|
3658
|
-
* `skillsRoot`
|
|
3659
|
-
*
|
|
3660
|
-
*
|
|
3661
|
-
*
|
|
3662
|
-
*
|
|
3663
|
-
*
|
|
3914
|
+
/** E-7 (v18) → V27 G2.4 (M-08): every family row reads ONE root key. `root` is
|
|
3915
|
+
* canonical; the `skillsRoot` alias was honoured for one minor version and its
|
|
3916
|
+
* window closed at 0.3.65 — it is now two releases past expiry, so this
|
|
3917
|
+
* resolver no longer reads it at all. A deployment that still sets the alias
|
|
3918
|
+
* must fail LOUDLY at load (see {@link assertSkillsRootAliasRetired}): silently
|
|
3919
|
+
* ignoring a config key leaves the deployment pointing at a root nobody reads,
|
|
3920
|
+
* which is the worst form of compatibility.
|
|
3921
|
+
* @param config - the raw plugin config.
|
|
3922
|
+
* @returns the effective root (empty when the key is unset or blank).
|
|
3664
3923
|
*/
|
|
3665
3924
|
function resolveRootConfig(config = {}) {
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
usedDeprecatedAlias: true
|
|
3678
|
-
};
|
|
3925
|
+
return { root: (config.root ?? "").trim() };
|
|
3926
|
+
}
|
|
3927
|
+
/** V27 G2.4 (M-08): the retirement gate for the expired `skillsRoot` alias.
|
|
3928
|
+
* Called at each plugin's load boundary (before the root is resolved), it turns
|
|
3929
|
+
* a stale key into an explicit load error naming the replacement — the
|
|
3930
|
+
* fail-loud form the plan requires instead of a silent no-op.
|
|
3931
|
+
* @param config - the raw plugin config (the alias field stays DECLARED in each
|
|
3932
|
+
* schema so the loader can hand it here instead of dropping it).
|
|
3933
|
+
*/
|
|
3934
|
+
function assertSkillsRootAliasRetired(config = {}) {
|
|
3935
|
+
if ((config.skillsRoot ?? "").trim() !== "") throw new Error("evolution: config \"skillsRoot\" was removed after 0.3.65 — rename the key to \"root\" (the alias is no longer honoured)");
|
|
3679
3936
|
}
|
|
3680
3937
|
/**
|
|
3681
3938
|
* Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
|
|
@@ -3729,21 +3986,28 @@ function markerPath(dir, marker) {
|
|
|
3729
3986
|
}
|
|
3730
3987
|
/**
|
|
3731
3988
|
* Shared frontmatter block detection (P3-3 single owner): opening line `---`
|
|
3732
|
-
* and closing line exactly
|
|
3733
|
-
* `
|
|
3989
|
+
* and closing line exactly `---`. Used by `parseFrontmatter`,
|
|
3990
|
+
* `frontmatterCatalogInvalid` and `normalizeFrontmatter` so the three can
|
|
3734
3991
|
* never disagree about where the block ends (the loose `indexOf('\n---')`
|
|
3735
3992
|
* form matched `\n----` and was replaced by this strict line rule).
|
|
3993
|
+
*
|
|
3994
|
+
* V27 G2.1: both fence lines are matched EXACTLY, tolerating only a trailing
|
|
3995
|
+
* `\r` — the same rule the upstream filesystem catalog uses
|
|
3996
|
+
* (`skill-filesystem.parseFrontmatter`). The former `.trim()` comparison
|
|
3997
|
+
* accepted ` --- `, so an indented fence loaded in the family while the
|
|
3998
|
+
* platform ignored the file: family visibility split from platform visibility,
|
|
3999
|
+
* which is exactly what a strict-YAML frontmatter is supposed to prevent.
|
|
3736
4000
|
*/
|
|
3737
4001
|
function frontmatterBlock(content) {
|
|
3738
4002
|
if (!content.trimStart().startsWith("---")) return null;
|
|
3739
4003
|
const nl = content.includes("\r\n") ? "\r\n" : "\n";
|
|
3740
4004
|
const lines = content.split(nl);
|
|
3741
|
-
if ((lines[0] ?? "").
|
|
4005
|
+
if ((lines[0] ?? "").replace(/\r$/, "") !== "---") return null;
|
|
3742
4006
|
let end = -1;
|
|
3743
4007
|
for (let i = 1; i < lines.length; i++) {
|
|
3744
4008
|
const line = lines[i];
|
|
3745
4009
|
if (line === void 0) continue;
|
|
3746
|
-
if (line.
|
|
4010
|
+
if (line.replace(/\r$/, "") === "---") {
|
|
3747
4011
|
end = i;
|
|
3748
4012
|
break;
|
|
3749
4013
|
}
|
|
@@ -3756,24 +4020,177 @@ function frontmatterBlock(content) {
|
|
|
3756
4020
|
nl
|
|
3757
4021
|
};
|
|
3758
4022
|
}
|
|
3759
|
-
|
|
4023
|
+
/**
|
|
4024
|
+
* Raw-line scan of a frontmatter block: the single owner of "which entries the
|
|
4025
|
+
* strict catalog cannot load". `frontmatterCatalogInvalid` publishes it and
|
|
4026
|
+
* `normalizeFrontmatter` decides each rewrite with the same predicate
|
|
4027
|
+
* (`yamlPlainScalarNeedsQuotes`), so the audit verdict and the write path can
|
|
4028
|
+
* never disagree. Only single-line `key: value` entries are judged; a line with
|
|
4029
|
+
* embedded breaks is skipped.
|
|
4030
|
+
*/
|
|
4031
|
+
function unsafeFrontmatterEntries(block, nl) {
|
|
4032
|
+
const found = [];
|
|
4033
|
+
for (const line of block.split(nl)) {
|
|
4034
|
+
if (line.includes("\n") || line.includes("\r")) continue;
|
|
4035
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
4036
|
+
if (!match) continue;
|
|
4037
|
+
const key = match[1];
|
|
4038
|
+
if (key === void 0) continue;
|
|
4039
|
+
const value = (match[2] ?? "").trim();
|
|
4040
|
+
if (yamlPlainScalarNeedsQuotes(value)) found.push({
|
|
4041
|
+
key,
|
|
4042
|
+
value
|
|
4043
|
+
});
|
|
4044
|
+
}
|
|
4045
|
+
return found;
|
|
4046
|
+
}
|
|
4047
|
+
/**
|
|
4048
|
+
* Frontmatter values as the STRICT platform catalog reads them — js-yaml, the
|
|
4049
|
+
* parser `normalizeFrontmatter` also verifies rewrites with — or `null` when
|
|
4050
|
+
* the block is not loadable as a YAML mapping.
|
|
4051
|
+
*
|
|
4052
|
+
* Scalars publish their text (`name`, `description`, `whenToUse` are strings by
|
|
4053
|
+
* contract; a number/boolean-shaped value keeps the text the family always
|
|
4054
|
+
* published), a flow or block sequence publishes its inline `[a, b]` form
|
|
4055
|
+
* (`relatedSkillNames` scans names out of it), and a nested mapping publishes
|
|
4056
|
+
* nothing — no consumer in this family reads one, and a lossy string could be
|
|
4057
|
+
* picked up by a routing field. A string value is trimmed: YAML's block-scalar
|
|
4058
|
+
* chomping appends a newline that the family's single-line routing fields never
|
|
4059
|
+
* carried.
|
|
4060
|
+
*/
|
|
4061
|
+
function strictFrontmatterValues(block) {
|
|
4062
|
+
if (block.trim() === "") return /* @__PURE__ */ new Map();
|
|
4063
|
+
let loaded;
|
|
4064
|
+
try {
|
|
4065
|
+
loaded = load(block);
|
|
4066
|
+
} catch {
|
|
4067
|
+
return null;
|
|
4068
|
+
}
|
|
4069
|
+
if (loaded === null || loaded === void 0) return /* @__PURE__ */ new Map();
|
|
4070
|
+
if (typeof loaded !== "object" || Array.isArray(loaded)) return null;
|
|
4071
|
+
const values = /* @__PURE__ */ new Map();
|
|
4072
|
+
for (const [key, value] of Object.entries(loaded)) {
|
|
4073
|
+
if (typeof value === "string") {
|
|
4074
|
+
values.set(key, value.trim());
|
|
4075
|
+
continue;
|
|
4076
|
+
}
|
|
4077
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
4078
|
+
values.set(key, String(value));
|
|
4079
|
+
continue;
|
|
4080
|
+
}
|
|
4081
|
+
if (Array.isArray(value)) {
|
|
4082
|
+
values.set(key, `[${value.map((item) => String(item)).join(", ")}]`);
|
|
4083
|
+
continue;
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
return values;
|
|
4087
|
+
}
|
|
4088
|
+
/**
|
|
4089
|
+
* Lenient line scan, used only for a block the strict parser rejects: the
|
|
4090
|
+
* family keeps routing a legacy file the platform refuses, and
|
|
4091
|
+
* `catalogInvalid` reports the split instead of hiding it. Values are trimmed
|
|
4092
|
+
* and unquoted exactly as before.
|
|
4093
|
+
*/
|
|
4094
|
+
function lenientFrontmatterValues(block, nl) {
|
|
4095
|
+
const values = /* @__PURE__ */ new Map();
|
|
4096
|
+
const blockLines = block.split(nl);
|
|
4097
|
+
for (let i = 0; i < blockLines.length; i += 1) {
|
|
4098
|
+
const line = blockLines[i] ?? "";
|
|
4099
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
4100
|
+
if (!match) continue;
|
|
4101
|
+
const [, key, value] = match;
|
|
4102
|
+
if (key === void 0 || value === void 0) continue;
|
|
4103
|
+
const header = /^([>|])[+-]?\d*$/.exec(value.trim());
|
|
4104
|
+
if (header !== null) {
|
|
4105
|
+
const fold = header[1] === ">";
|
|
4106
|
+
const parts = [];
|
|
4107
|
+
let scan = i + 1;
|
|
4108
|
+
for (; scan < blockLines.length; scan += 1) {
|
|
4109
|
+
const raw = blockLines[scan] ?? "";
|
|
4110
|
+
if (raw.trim() === "") {
|
|
4111
|
+
parts.push("");
|
|
4112
|
+
continue;
|
|
4113
|
+
}
|
|
4114
|
+
if (!/^\s/.test(raw)) break;
|
|
4115
|
+
parts.push(raw.trim());
|
|
4116
|
+
}
|
|
4117
|
+
while (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
|
|
4118
|
+
let folded = "";
|
|
4119
|
+
for (const part of parts) {
|
|
4120
|
+
if (part === "") {
|
|
4121
|
+
if (folded !== "" && !folded.endsWith("\n")) folded += "\n";
|
|
4122
|
+
continue;
|
|
4123
|
+
}
|
|
4124
|
+
if (folded === "" || folded.endsWith("\n")) folded += part;
|
|
4125
|
+
else folded += ` ${part}`;
|
|
4126
|
+
}
|
|
4127
|
+
values.set(key, (fold ? folded : parts.join("\n")).trim());
|
|
4128
|
+
i = scan - 1;
|
|
4129
|
+
continue;
|
|
4130
|
+
}
|
|
4131
|
+
values.set(key, value.trim().replace(/^["']|["']$/g, ""));
|
|
4132
|
+
}
|
|
4133
|
+
return values;
|
|
4134
|
+
}
|
|
4135
|
+
/**
|
|
4136
|
+
* The single read of a SKILL.md frontmatter block: values, body and every
|
|
4137
|
+
* strict-catalog signal, computed once. `null` only when the file has no
|
|
4138
|
+
* frontmatter block; the body may be empty. {@link parseFrontmatter} and
|
|
4139
|
+
* {@link frontmatterCatalogInvalid} are its two projections.
|
|
4140
|
+
*
|
|
4141
|
+
* @param content - the SKILL.md text.
|
|
4142
|
+
* @returns the block read, or `null` when there is no block.
|
|
4143
|
+
*/
|
|
4144
|
+
function readFrontmatterBlock(content) {
|
|
3760
4145
|
const found = frontmatterBlock(content);
|
|
3761
4146
|
if (!found) return null;
|
|
3762
4147
|
const body = found.lines.slice(found.end + 1).join(found.nl).trim();
|
|
3763
|
-
|
|
4148
|
+
const strict = strictFrontmatterValues(found.block);
|
|
4149
|
+
const values = strict ?? lenientFrontmatterValues(found.block, found.nl);
|
|
3764
4150
|
const frontmatter = {};
|
|
3765
|
-
for (const
|
|
3766
|
-
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
3767
|
-
if (match) {
|
|
3768
|
-
const [, key, value] = match;
|
|
3769
|
-
if (key && value !== void 0) frontmatter[key] = value.trim().replace(/^["']|["']$/g, "");
|
|
3770
|
-
}
|
|
3771
|
-
}
|
|
4151
|
+
for (const [key, value] of values) frontmatter[key] = value;
|
|
3772
4152
|
return {
|
|
3773
4153
|
frontmatter,
|
|
3774
|
-
body
|
|
4154
|
+
body,
|
|
4155
|
+
unsafeValues: unsafeFrontmatterEntries(found.block, found.nl),
|
|
4156
|
+
strictFailed: strict === null
|
|
4157
|
+
};
|
|
4158
|
+
}
|
|
4159
|
+
/**
|
|
4160
|
+
* Parse a SKILL.md: its frontmatter values and body, or `null` when the file
|
|
4161
|
+
* has no frontmatter block or no body. Every consumer of frontmatter values
|
|
4162
|
+
* goes through here — the write path's validation, `list()`'s published
|
|
4163
|
+
* description, `relatedSkillNames` and the audit — so all of them read the same
|
|
4164
|
+
* bytes the same way.
|
|
4165
|
+
*
|
|
4166
|
+
* @param content - the SKILL.md text.
|
|
4167
|
+
* @returns the read, or `null` when there is no block or no body.
|
|
4168
|
+
*/
|
|
4169
|
+
function parseFrontmatter(content) {
|
|
4170
|
+
const read = readFrontmatterBlock(content);
|
|
4171
|
+
if (!read || !read.body) return null;
|
|
4172
|
+
return {
|
|
4173
|
+
frontmatter: read.frontmatter,
|
|
4174
|
+
body: read.body,
|
|
4175
|
+
unsafeValues: read.unsafeValues,
|
|
4176
|
+
catalogInvalid: read.strictFailed || read.unsafeValues.length > 0
|
|
3775
4177
|
};
|
|
3776
4178
|
}
|
|
4179
|
+
/**
|
|
4180
|
+
* Whether this file's frontmatter is valid as written for the strict platform
|
|
4181
|
+
* catalog (see `FrontmatterRead.catalogInvalid`). Body-independent (a body-less
|
|
4182
|
+
* file is still judged), and derived from the same read as `parseFrontmatter` —
|
|
4183
|
+
* so the audit's verdict and the values the family publishes for one file can
|
|
4184
|
+
* never disagree (V27 G2.1).
|
|
4185
|
+
*
|
|
4186
|
+
* @param content - the SKILL.md text.
|
|
4187
|
+
* @returns `true` when the strict parser rejects the block or an unquoted value would read as something else.
|
|
4188
|
+
*/
|
|
4189
|
+
function frontmatterCatalogInvalid(content) {
|
|
4190
|
+
const read = readFrontmatterBlock(content);
|
|
4191
|
+
if (read !== null) return read.strictFailed || read.unsafeValues.length > 0;
|
|
4192
|
+
return frontmatterBlock(content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n")) !== null;
|
|
4193
|
+
}
|
|
3777
4194
|
/** YAML plain-scalar hazards that make an UNQUOTED frontmatter value
|
|
3778
4195
|
* unloadable to the platform catalog (strict YAML parser): `: ` (mapping
|
|
3779
4196
|
* separator), ` #` (comment start), a trailing `:` (a mapping marker),
|
|
@@ -3792,36 +4209,18 @@ function yamlPlainScalarNeedsQuotes(value) {
|
|
|
3792
4209
|
if (value.length === 0) return false;
|
|
3793
4210
|
if (value.length >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) return false;
|
|
3794
4211
|
if (/^\[.*\]$/.test(value) || /^\{.*\}$/.test(value)) return false;
|
|
4212
|
+
if (/^[>|][+-]?\d*$/.test(value)) return false;
|
|
3795
4213
|
if (value.includes(": ")) return true;
|
|
3796
4214
|
if (value.includes(" #")) return true;
|
|
3797
4215
|
if (value.endsWith(":")) return true;
|
|
3798
|
-
if (/^(?:null|true|false
|
|
4216
|
+
if (/^(?:null|true|false|~)$/i.test(value)) return true;
|
|
4217
|
+
if (/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/.test(value)) return true;
|
|
4218
|
+
if (/^0x[0-9a-f]+$/i.test(value)) return true;
|
|
4219
|
+
if (/^0o[0-7]+$/.test(value)) return true;
|
|
4220
|
+
if (/^\.(?:inf|nan)$/i.test(value)) return true;
|
|
3799
4221
|
if (/^[-?:,[\]{}#&*!|>'\"%@`\s]/.test(value)) return true;
|
|
3800
4222
|
return false;
|
|
3801
4223
|
}
|
|
3802
|
-
/** Raw-line scan of the frontmatter block: entries whose UNQUOTED value is
|
|
3803
|
-
* YAML-unsafe for the strict platform catalog. Operates on the ORIGINAL line
|
|
3804
|
-
* value (quotes included), so a value already wrapped by
|
|
3805
|
-
* `normalizeFrontmatter` is never re-flagged — one source with the write
|
|
3806
|
-
* path. Single-line entries only; lines with embedded line breaks skip. */
|
|
3807
|
-
function frontmatterYamlUnsafeValues(content) {
|
|
3808
|
-
const found = [];
|
|
3809
|
-
const block = frontmatterBlock(content);
|
|
3810
|
-
if (!block) return found;
|
|
3811
|
-
for (const line of block.block.split(block.nl)) {
|
|
3812
|
-
if (line.includes("\n") || line.includes("\r")) continue;
|
|
3813
|
-
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
3814
|
-
if (!match) continue;
|
|
3815
|
-
const key = match[1];
|
|
3816
|
-
const value = (match[2] ?? "").trim();
|
|
3817
|
-
if (key === void 0) continue;
|
|
3818
|
-
if (yamlPlainScalarNeedsQuotes(value)) found.push({
|
|
3819
|
-
key,
|
|
3820
|
-
value
|
|
3821
|
-
});
|
|
3822
|
-
}
|
|
3823
|
-
return found;
|
|
3824
|
-
}
|
|
3825
4224
|
/**
|
|
3826
4225
|
* Normalize a SKILL.md frontmatter block into catalog-loadable YAML: values
|
|
3827
4226
|
* that YAML forbids unquoted get quotes — double quotes normally, single
|
|
@@ -3931,7 +4330,7 @@ function relatedSkillNames(content, exclude) {
|
|
|
3931
4330
|
}
|
|
3932
4331
|
function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS) {
|
|
3933
4332
|
const parsed = parseFrontmatter(content);
|
|
3934
|
-
if (!parsed) return "SKILL.md must start
|
|
4333
|
+
if (!parsed) return "SKILL.md must start with a `---` line, close the frontmatter with another exact `---` line (only a trailing `\\r` is tolerated), and include a body below it.";
|
|
3935
4334
|
if (!parsed.frontmatter.name) return "Frontmatter must include a name field.";
|
|
3936
4335
|
if (!SKILL_NAME_RE.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
|
|
3937
4336
|
if (parsed.frontmatter.name.length > limits.maxNameLength) return `Skill name exceeds ${limits.maxNameLength} characters.`;
|
|
@@ -3965,9 +4364,18 @@ function authoringFeedback(frontmatter) {
|
|
|
3965
4364
|
/** A1-15 (v18) / P2-2 (v19): the io layer marks an error `committed: true` when
|
|
3966
4365
|
* the rename landed and only the directory fsync failed. Every single-file
|
|
3967
4366
|
* writer must treat that as "written, durability unconfirmed" — never as a
|
|
3968
|
-
* plain failure (which a caller would retry, or a two-phase caller roll back).
|
|
4367
|
+
* plain failure (which a caller would retry, or a two-phase caller roll back).
|
|
4368
|
+
* v28 G2.1 (EVO-IO-05): this is a delegation to the seam's own
|
|
4369
|
+
* `isCommittedWarning` — the marker predicate has exactly one definition. */
|
|
3969
4370
|
function isCommittedOnly(error) {
|
|
3970
|
-
return error
|
|
4371
|
+
return isCommittedWarning(error);
|
|
4372
|
+
}
|
|
4373
|
+
/** v28 G2.5 (CORE-SK-03): every pre-clear refusal in restoreSnapshotIntoRoot
|
|
4374
|
+
* says "refus…" (manifest / traversal / live-writer gates) or "is incomplete"
|
|
4375
|
+
* (completeness gate). Anything else thrown from that method means the
|
|
4376
|
+
* destructive clear already happened and a rollback is load-bearing. */
|
|
4377
|
+
function isPreClearRefusal(message) {
|
|
4378
|
+
return message.includes("refus") || message.includes("is incomplete");
|
|
3971
4379
|
}
|
|
3972
4380
|
async function listNames(root, io) {
|
|
3973
4381
|
const entries = await io.list(root);
|
|
@@ -4238,11 +4646,14 @@ var SkillLibrary = class {
|
|
|
4238
4646
|
let outcome;
|
|
4239
4647
|
const run = async (current) => {
|
|
4240
4648
|
const o = await task(current ?? null);
|
|
4241
|
-
outcome =
|
|
4649
|
+
outcome = {
|
|
4650
|
+
...o,
|
|
4651
|
+
ghostDir: current === null && o.write === null
|
|
4652
|
+
};
|
|
4242
4653
|
return o.write ?? current ?? null;
|
|
4243
4654
|
};
|
|
4244
4655
|
let durabilityWarning = "";
|
|
4245
|
-
const committedOnly =
|
|
4656
|
+
const committedOnly = isCommittedOnly;
|
|
4246
4657
|
if (this.transact) try {
|
|
4247
4658
|
await this.transact(this.io, path, run);
|
|
4248
4659
|
} catch (error) {
|
|
@@ -4264,6 +4675,7 @@ var SkillLibrary = class {
|
|
|
4264
4675
|
ok: false,
|
|
4265
4676
|
message: "internal error: the write transaction did not invoke the task; no write was performed"
|
|
4266
4677
|
};
|
|
4678
|
+
if (o.ghostDir === true) await this.cleanupGhostDir(path);
|
|
4267
4679
|
if (o.write !== null && o.audit) await this.audit(o.audit.skillName, o.audit.action, o.audit.before, o.audit.after, o.audit.summary);
|
|
4268
4680
|
if (o.write !== null && o.event) this.notifyMutation(o.event);
|
|
4269
4681
|
return durabilityWarning === "" || !o.result.ok ? o.result : {
|
|
@@ -4271,6 +4683,24 @@ var SkillLibrary = class {
|
|
|
4271
4683
|
message: `${o.result.message} (warning: the write landed but the directory fsync failed — durability unconfirmed: ${durabilityWarning})`
|
|
4272
4684
|
};
|
|
4273
4685
|
}
|
|
4686
|
+
/**
|
|
4687
|
+
* v28 G1.1 (EVO-IO-02): shared compensating cleanup for a locked write that
|
|
4688
|
+
* found no SKILL.md — remove the possibly-resurrected directory ONLY when it
|
|
4689
|
+
* holds nothing but this path's own write-lock file. The BR-5 rule from
|
|
4690
|
+
* setPinnedCore/createCore applies unchanged: a concurrent mover can land a
|
|
4691
|
+
* full directory between the list probe and the remove, so anything beyond
|
|
4692
|
+
* the lock file (support files, a fresh restore) must never be recursed
|
|
4693
|
+
* away. Best-effort: a failed cleanup leaves the "not found" result
|
|
4694
|
+
* unchanged (the operator-facing ghost-dir refusal is fail-loud already).
|
|
4695
|
+
*/
|
|
4696
|
+
async cleanupGhostDir(skillFilePath) {
|
|
4697
|
+
const dir = dirname(skillFilePath);
|
|
4698
|
+
const lockName = `${basename(skillFilePath)}${LOCK_SUFFIX}`;
|
|
4699
|
+
try {
|
|
4700
|
+
if ((await this.io.list(dir)).some((entry) => entry !== lockName)) return;
|
|
4701
|
+
await this.io.remove(dir);
|
|
4702
|
+
} catch {}
|
|
4703
|
+
}
|
|
4274
4704
|
/** Notify the mutation observer after a successful write; observers must never fail the mutation. */
|
|
4275
4705
|
notifyMutation(event) {
|
|
4276
4706
|
try {
|
|
@@ -4288,11 +4718,39 @@ var SkillLibrary = class {
|
|
|
4288
4718
|
contentThreatBlock(content) {
|
|
4289
4719
|
return scanContentThreats(content, void 0, this.threatScanOptions());
|
|
4290
4720
|
}
|
|
4721
|
+
/**
|
|
4722
|
+
* v30 REV-03: read a support file's bytes for staleness anchoring (the
|
|
4723
|
+
* write/remove replay guard). Same validation as writeSupportFile; a
|
|
4724
|
+
* missing file (or a directory squatting on the path) reads as `null`, any
|
|
4725
|
+
* other failure RETHROWS — the callers are the staging/replay anchors, and
|
|
4726
|
+
* a swallowed error would silently downgrade the anchor to
|
|
4727
|
+
* last-writer-wins.
|
|
4728
|
+
*/
|
|
4729
|
+
async readSupportFile(name, filePath) {
|
|
4730
|
+
const bad = this.badName(name, { allowReserved: true });
|
|
4731
|
+
if (bad) throw new Error(bad);
|
|
4732
|
+
const validation = validateSupportPath(filePath);
|
|
4733
|
+
if (validation) throw new Error(validation);
|
|
4734
|
+
const target = join(this.dirOf(name), ...filePath.replace(/\\/g, "/").split("/").filter(Boolean));
|
|
4735
|
+
try {
|
|
4736
|
+
return await this.io.readText(target);
|
|
4737
|
+
} catch (error) {
|
|
4738
|
+
const code = error?.code;
|
|
4739
|
+
if (code === "ENOENT" || code === "EISDIR") return null;
|
|
4740
|
+
throw error;
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4291
4743
|
async list() {
|
|
4292
4744
|
const summaries = [];
|
|
4293
4745
|
for (const name of await listNames(this.root, this.io)) {
|
|
4294
4746
|
const dir = this.dirOf(name);
|
|
4295
|
-
|
|
4747
|
+
let md;
|
|
4748
|
+
try {
|
|
4749
|
+
md = await this.io.readText(join(dir, "SKILL.md"));
|
|
4750
|
+
} catch (error) {
|
|
4751
|
+
if (error?.code === "EISDIR") continue;
|
|
4752
|
+
throw error;
|
|
4753
|
+
}
|
|
4296
4754
|
if (md === null) continue;
|
|
4297
4755
|
const parsed = parseFrontmatter(md);
|
|
4298
4756
|
let entries = null;
|
|
@@ -4552,7 +5010,7 @@ var SkillLibrary = class {
|
|
|
4552
5010
|
}
|
|
4553
5011
|
if (!await this.io.exists(join(dir, "SKILL.md"))) {
|
|
4554
5012
|
await this.io.remove(marker).catch(() => {});
|
|
4555
|
-
|
|
5013
|
+
await this.cleanupGhostDir(join(dir, "SKILL.md"));
|
|
4556
5014
|
return {
|
|
4557
5015
|
ok: false,
|
|
4558
5016
|
message: `Skill "${normalized}" was archived concurrently while pinning; the partial marker was removed — retry after the mover settles.`
|
|
@@ -4652,15 +5110,22 @@ var SkillLibrary = class {
|
|
|
4652
5110
|
message: `Skill "${normalized}" already exists.`
|
|
4653
5111
|
};
|
|
4654
5112
|
if (origin !== "foreground") {
|
|
4655
|
-
|
|
5113
|
+
let markerDurabilityWarning = "";
|
|
5114
|
+
try {
|
|
5115
|
+
await this.io.writeText(markerPath(dir, "hermes-managed"), "");
|
|
5116
|
+
} catch (error) {
|
|
5117
|
+
if (!isCommittedOnly(error)) throw error;
|
|
5118
|
+
markerDurabilityWarning = error instanceof Error ? error.message : String(error);
|
|
5119
|
+
}
|
|
4656
5120
|
if (!await this.io.exists(createPath)) {
|
|
4657
5121
|
await this.io.remove(markerPath(dir, "hermes-managed")).catch(() => {});
|
|
4658
|
-
|
|
5122
|
+
await this.cleanupGhostDir(createPath);
|
|
4659
5123
|
return {
|
|
4660
5124
|
ok: false,
|
|
4661
5125
|
message: `Skill "${normalized}" was archived concurrently while being created; the partial marker was removed — retry once the mover settles.`
|
|
4662
5126
|
};
|
|
4663
5127
|
}
|
|
5128
|
+
if (markerDurabilityWarning !== "") createDurabilityWarning = createDurabilityWarning === "" ? markerDurabilityWarning : `${createDurabilityWarning}; marker: ${markerDurabilityWarning}`;
|
|
4664
5129
|
}
|
|
4665
5130
|
await this.audit(normalized, "create", null, onDisk, "created");
|
|
4666
5131
|
this.notifyMutation({
|
|
@@ -4819,6 +5284,8 @@ var SkillLibrary = class {
|
|
|
4819
5284
|
},
|
|
4820
5285
|
write: null
|
|
4821
5286
|
};
|
|
5287
|
+
const anchorCount = oldString === "" ? 0 : md.split(oldString).length - 1;
|
|
5288
|
+
const patchNote = !replaceAll && anchorCount > 1 ? ` Replaced the FIRST of ${anchorCount} occurrences; pass replace_all=true to change every one.` : "";
|
|
4822
5289
|
let writeContent = patched;
|
|
4823
5290
|
let normalizedFields;
|
|
4824
5291
|
if (target === skillMd) {
|
|
@@ -4886,7 +5353,7 @@ var SkillLibrary = class {
|
|
|
4886
5353
|
return {
|
|
4887
5354
|
result: {
|
|
4888
5355
|
ok: true,
|
|
4889
|
-
message: `Skill "${name}" patched (${patchLabel})
|
|
5356
|
+
message: `Skill "${name}" patched (${patchLabel}).${patchNote}`,
|
|
4890
5357
|
path: dir,
|
|
4891
5358
|
...normalizedFields ? { normalizedFrontmatterFields: normalizedFields } : {}
|
|
4892
5359
|
},
|
|
@@ -5100,6 +5567,9 @@ var SkillLibrary = class {
|
|
|
5100
5567
|
try {
|
|
5101
5568
|
await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
|
|
5102
5569
|
} catch {}
|
|
5570
|
+
try {
|
|
5571
|
+
await this.io.writeText(join(dest, ".archive-name"), `${name}\n`);
|
|
5572
|
+
} catch {}
|
|
5103
5573
|
await this.audit(name, "archive", md, null, reason);
|
|
5104
5574
|
this.notifyMutation({
|
|
5105
5575
|
action: "archive",
|
|
@@ -5482,7 +5952,7 @@ var SkillLibrary = class {
|
|
|
5482
5952
|
if (drift.seen) throw new Error(`concurrent modification detected: ${entry.target} changed after the plan was computed (a concurrent writer won the race); no further writes were performed`);
|
|
5483
5953
|
} else await this.io.writeText(entry.target, entry.content);
|
|
5484
5954
|
} catch (error) {
|
|
5485
|
-
if (error
|
|
5955
|
+
if (!isCommittedOnly(error)) throw error;
|
|
5486
5956
|
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
5487
5957
|
}
|
|
5488
5958
|
written.push({
|
|
@@ -5491,10 +5961,21 @@ var SkillLibrary = class {
|
|
|
5491
5961
|
});
|
|
5492
5962
|
}
|
|
5493
5963
|
} catch (error) {
|
|
5494
|
-
|
|
5964
|
+
const stuck = [];
|
|
5965
|
+
for (const entry of written.reverse()) try {
|
|
5966
|
+
if (entry.previous === null) await this.io.remove(entry.target);
|
|
5967
|
+
else await this.io.writeText(entry.target, entry.previous);
|
|
5968
|
+
} catch {
|
|
5969
|
+
stuck.push(entry.target);
|
|
5970
|
+
}
|
|
5971
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
5972
|
+
if (stuck.length === 0) return {
|
|
5973
|
+
ok: false,
|
|
5974
|
+
message: `Tree change failed and was rolled back: ${reason}`
|
|
5975
|
+
};
|
|
5495
5976
|
return {
|
|
5496
5977
|
ok: false,
|
|
5497
|
-
message: `Tree change failed
|
|
5978
|
+
message: `Tree change failed: ${reason}. The rollback could not restore ${stuck.join(", ")} — recover those targets from the .backups snapshot (or re-apply the change).`
|
|
5498
5979
|
};
|
|
5499
5980
|
}
|
|
5500
5981
|
await this.audit(name, plan.auditAction, md, landing.find((entry) => entry.target.split(/[\\/]/).pop() === "SKILL.md")?.content ?? md, plan.auditSummary);
|
|
@@ -5538,14 +6019,33 @@ var SkillLibrary = class {
|
|
|
5538
6019
|
}
|
|
5539
6020
|
const candidates = entries.filter((entry) => entry === name || entry.startsWith(`${name}-`)).sort().reverse();
|
|
5540
6021
|
let chosen;
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
6022
|
+
const unreadable = [];
|
|
6023
|
+
for (const candidate of candidates) {
|
|
6024
|
+
if ((await this.io.readText(join(archiveRoot, candidate, ".archive-name")).catch(() => null))?.trim() === name) {
|
|
6025
|
+
chosen = candidate;
|
|
6026
|
+
break;
|
|
6027
|
+
}
|
|
6028
|
+
const parsed = parseFrontmatter(await this.io.readText(join(archiveRoot, candidate, "SKILL.md")).catch(() => null) ?? "");
|
|
6029
|
+
if (parsed?.frontmatter.name === name) {
|
|
6030
|
+
chosen = candidate;
|
|
6031
|
+
break;
|
|
6032
|
+
}
|
|
6033
|
+
if (parsed === null && candidate === name) {
|
|
6034
|
+
chosen = candidate;
|
|
6035
|
+
break;
|
|
6036
|
+
}
|
|
6037
|
+
if (parsed === null) unreadable.push(candidate);
|
|
6038
|
+
}
|
|
6039
|
+
if (!chosen) {
|
|
6040
|
+
if (unreadable.length === 0) return {
|
|
6041
|
+
ok: false,
|
|
6042
|
+
message: `Skill "${name}" is not in .archive.`
|
|
6043
|
+
};
|
|
6044
|
+
return {
|
|
6045
|
+
ok: false,
|
|
6046
|
+
message: `No archived entry for "${name}" carries a matching frontmatter name, but .archive holds ${unreadable.length} entr(y/ies) named like it: ${unreadable.join(", ")}. Those entries have no readable SKILL.md (or none matching the name), so restoring by name cannot tell them apart — rename the directory to the skill name and restore again, or restore from a snapshot.`
|
|
6047
|
+
};
|
|
5544
6048
|
}
|
|
5545
|
-
if (!chosen) return {
|
|
5546
|
-
ok: false,
|
|
5547
|
-
message: `Skill "${name}" is not in .archive.`
|
|
5548
|
-
};
|
|
5549
6049
|
const source = join(archiveRoot, chosen);
|
|
5550
6050
|
if (this.io.isSymlink) {
|
|
5551
6051
|
if (await this.io.isSymlink(source) === true) return {
|
|
@@ -5563,7 +6063,7 @@ var SkillLibrary = class {
|
|
|
5563
6063
|
message: `Restore of "${name}" from .archive failed: ${moveFailure}`
|
|
5564
6064
|
};
|
|
5565
6065
|
await this.deleteStrandedLocks(dest);
|
|
5566
|
-
if (await this.io.exists(join(dest,
|
|
6066
|
+
for (const marker of [".archive-reason", ".archive-name"]) if (await this.io.exists(join(dest, marker))) await this.io.remove(join(dest, marker));
|
|
5567
6067
|
await this.audit(name, "restore", null, await this.io.readText(join(dest, "SKILL.md")).catch(() => null), `restored from ${source}`);
|
|
5568
6068
|
this.notifyMutation({
|
|
5569
6069
|
action: "restore",
|
|
@@ -5758,7 +6258,9 @@ var SkillLibrary = class {
|
|
|
5758
6258
|
await this.io.copy(archiveRoot, join(dest, ".archive"));
|
|
5759
6259
|
hasArchive = true;
|
|
5760
6260
|
}
|
|
5761
|
-
const
|
|
6261
|
+
const rejectedExtras = extras.filter((extra) => typeof extra?.name !== "string" || !SNAPSHOT_EXTRA_NAME_RE.test(extra.name)).map((extra) => JSON.stringify(extra?.name ?? extra));
|
|
6262
|
+
if (rejectedExtras.length > 0) throw new Error(`snapshotAll: refusing invalid snapshot extras (name must match ${SNAPSHOT_EXTRA_NAME_RE.source}): ${rejectedExtras.join(", ")}`);
|
|
6263
|
+
const validExtras = extras;
|
|
5762
6264
|
const extraNames = validExtras.map((extra) => extra.name);
|
|
5763
6265
|
const extraFailure = (await Promise.allSettled(validExtras.map(async (extra) => {
|
|
5764
6266
|
await this.io.writeText(join(dest, "extras", extra.name), extra.content);
|
|
@@ -5799,6 +6301,27 @@ var SkillLibrary = class {
|
|
|
5799
6301
|
}
|
|
5800
6302
|
return out;
|
|
5801
6303
|
}
|
|
6304
|
+
/**
|
|
6305
|
+
* V27 G0.4 (core-a-01): the per-entry gate `skipped` already has. A corrupted
|
|
6306
|
+
* or hand-edited manifest could carry `extras: [123]`: the array check passed,
|
|
6307
|
+
* `SNAPSHOT_EXTRA_NAME_RE.test(123)` coerced the number to the string "123"
|
|
6308
|
+
* and matched, and the value then threw `TypeError` inside `path.join` — which
|
|
6309
|
+
* `readSnapshotExtras` reached only AFTER a whole-tree restore had committed.
|
|
6310
|
+
* Extras are path components under `extras/`, so they take the entry gate too;
|
|
6311
|
+
* bounded like `skipped` so a hostile manifest cannot grow the read set.
|
|
6312
|
+
*/
|
|
6313
|
+
sanitizeExtraNames(raw) {
|
|
6314
|
+
if (!Array.isArray(raw)) return [];
|
|
6315
|
+
const out = [];
|
|
6316
|
+
for (const entry of raw) {
|
|
6317
|
+
if (typeof entry !== "string") continue;
|
|
6318
|
+
if (!SNAPSHOT_EXTRA_NAME_RE.test(entry)) continue;
|
|
6319
|
+
if (!this.safeSnapshotEntryName(entry)) continue;
|
|
6320
|
+
out.push(entry);
|
|
6321
|
+
if (out.length >= 50) break;
|
|
6322
|
+
}
|
|
6323
|
+
return out;
|
|
6324
|
+
}
|
|
5802
6325
|
async readSnapshotManifest(path) {
|
|
5803
6326
|
const raw = await this.io.readText(join(path, "manifest.json"));
|
|
5804
6327
|
if (raw === null) return null;
|
|
@@ -5812,7 +6335,7 @@ var SkillLibrary = class {
|
|
|
5812
6335
|
skipped: this.sanitizeSkippedNames(manifest.skipped),
|
|
5813
6336
|
sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
|
|
5814
6337
|
...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
|
|
5815
|
-
extras:
|
|
6338
|
+
extras: this.sanitizeExtraNames(manifest.extras)
|
|
5816
6339
|
};
|
|
5817
6340
|
} catch {
|
|
5818
6341
|
return null;
|
|
@@ -5885,10 +6408,18 @@ var SkillLibrary = class {
|
|
|
5885
6408
|
ok: false,
|
|
5886
6409
|
message: "No skill snapshot available."
|
|
5887
6410
|
};
|
|
5888
|
-
|
|
5889
|
-
|
|
6411
|
+
let preRollbackPath;
|
|
6412
|
+
try {
|
|
6413
|
+
preRollbackPath = await this.snapshotAll("pre-rollback", extras);
|
|
6414
|
+
} catch (error) {
|
|
6415
|
+
return {
|
|
6416
|
+
ok: false,
|
|
6417
|
+
message: `Snapshot restore was refused before anything was cleared: taking the pre-rollback snapshot failed (${error instanceof Error ? error.message : String(error)}) — the active tree is UNCHANGED. Resolve the snapshot write failure and retry.`
|
|
6418
|
+
};
|
|
6419
|
+
}
|
|
6420
|
+
const snapshotExtras = await this.readSnapshotExtras(latest.path);
|
|
5890
6421
|
try {
|
|
5891
|
-
|
|
6422
|
+
await this.restoreSnapshotIntoRoot(latest.path);
|
|
5892
6423
|
} catch (error) {
|
|
5893
6424
|
const reason = error instanceof Error ? error.message : String(error);
|
|
5894
6425
|
try {
|
|
@@ -5898,21 +6429,24 @@ var SkillLibrary = class {
|
|
|
5898
6429
|
message: `Snapshot restore failed (${reason}); the active tree was rolled back to the pre-rollback snapshot.`
|
|
5899
6430
|
};
|
|
5900
6431
|
} catch (rollbackError) {
|
|
6432
|
+
const rb = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
|
|
6433
|
+
if (isPreClearRefusal(reason) && isPreClearRefusal(rb)) return {
|
|
6434
|
+
ok: false,
|
|
6435
|
+
message: `Snapshot restore was refused before anything was cleared; the active tree is UNCHANGED.\n- target: ${reason}\n- pre-rollback: ${rb}\nResolve the cause (a live skill write, or an incomplete snapshot) and retry — no manual rescue is needed.`
|
|
6436
|
+
};
|
|
5901
6437
|
return {
|
|
5902
6438
|
ok: false,
|
|
5903
|
-
message: `Snapshot restore failed (${reason}) AND pre-rollback restore failed (${
|
|
6439
|
+
message: `Snapshot restore failed (${reason}) AND pre-rollback restore failed (${rb}). Rescue manually from: ${preRollbackPath} (pre-rollback), ${latest.path} (target).`
|
|
5904
6440
|
};
|
|
5905
6441
|
}
|
|
5906
6442
|
}
|
|
5907
|
-
const snapshotExtras = await this.readSnapshotExtras(latest.path);
|
|
5908
6443
|
this.notifyMutation({
|
|
5909
6444
|
action: "restore",
|
|
5910
6445
|
name: "snapshot"
|
|
5911
6446
|
});
|
|
5912
|
-
const skippedNote = skipped.length === 0 ? "" : ` NOTE: ${skipped.length} skill(s) were skipped when this snapshot was taken (a live writer held their lock) and are NOT restored: ${skipped.join(", ")} — recover them from .backups if a copy exists.`;
|
|
5913
6447
|
return {
|
|
5914
6448
|
ok: true,
|
|
5915
|
-
message: `Restored skill tree from ${latest.path}
|
|
6449
|
+
message: `Restored skill tree from ${latest.path}`,
|
|
5916
6450
|
path: latest.path,
|
|
5917
6451
|
...snapshotExtras.length === 0 ? {} : { extras: snapshotExtras }
|
|
5918
6452
|
};
|
|
@@ -5927,6 +6461,7 @@ var SkillLibrary = class {
|
|
|
5927
6461
|
const manifest = await this.readSnapshotManifest(snapshotPath);
|
|
5928
6462
|
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`);
|
|
5929
6463
|
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`);
|
|
6464
|
+
if (manifest.skipped.length > 0) throw new Error(`snapshot ${snapshotPath} is incomplete: ${manifest.skipped.length} skill(s) were skipped when it was taken (a live writer held their write lock: ${manifest.skipped.join(", ")}); restoring it would clear the active tree without bringing them back — let the writer finish and take a fresh snapshot, then restore that one`);
|
|
5930
6465
|
if (manifest.skills.length === 0) {
|
|
5931
6466
|
const snapshotEntries = await this.io.list(snapshotPath);
|
|
5932
6467
|
const declared = new Set([
|
|
@@ -5977,8 +6512,7 @@ var SkillLibrary = class {
|
|
|
5977
6512
|
if (entry.startsWith(".")) continue;
|
|
5978
6513
|
await this.deleteStrandedLocks(join(this.root, entry));
|
|
5979
6514
|
}
|
|
5980
|
-
return manifest.skipped;
|
|
5981
6515
|
}
|
|
5982
6516
|
};
|
|
5983
6517
|
//#endregion
|
|
5984
|
-
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_EXEMPTION_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,
|
|
6518
|
+
export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEAD_LOCK_TAKEOVER_MS, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_MODEL, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MEMORY_REVIEW_MODEL, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_MODEL, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, EMPTY_LOCK_TAKEOVER_MS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, LOCK_BODY_RE, LOCK_SUFFIX, LOCK_TEAR_TAKEOVER_MS, LOW_QUALITY_THRESHOLD, LostWriteLock, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MAX_TIMER_DELAY_MS, MEMORY_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_EXEMPTION_HINT, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorFields, applyCuratorLifecycleFields, applyCuratorMetaFields, assertSkillsRootAliasRetired, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, clampedNumber, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, createGateSet, decideTakeover, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldTurn, frontmatterBlock, frontmatterCatalogInvalid, getRecord, isCommittedWarning, isProcessAlive, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, 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 };
|