@lmzhen/dsh-evolution-core 0.3.66 → 0.3.68
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 +596 -206
- package/lib/types/constants.d.ts +14 -0
- package/lib/types/curator.d.ts +14 -0
- package/lib/types/events.d.ts +19 -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 +48 -13
- package/lib/types/skill-store.d.ts +99 -13
- 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,
|
|
@@ -426,7 +516,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
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) {
|
|
@@ -796,6 +891,14 @@ function parseSuppressed(raw) {
|
|
|
796
891
|
}
|
|
797
892
|
}
|
|
798
893
|
async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
|
|
894
|
+
const current = await io.readText(suppressedFile(root)).catch(() => null);
|
|
895
|
+
if (current !== null) try {
|
|
896
|
+
const parsed = JSON.parse(current);
|
|
897
|
+
if (parsed !== null && typeof parsed.version === "number" && parsed.version > 1) {
|
|
898
|
+
console.warn(`suppression sidecar ${suppressedFile(root)} declares version ${String(parsed.version)} (newer than 1); not overwritten`);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
} catch {}
|
|
799
902
|
await io.writeText(suppressedFile(root), JSON.stringify({
|
|
800
903
|
version: 1,
|
|
801
904
|
names: [...names].sort()
|
|
@@ -809,10 +912,17 @@ async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
|
|
|
809
912
|
*/
|
|
810
913
|
async function updateSuppressedNames(root, io, task) {
|
|
811
914
|
await transactIo(io, suppressedFile(root), async (current) => {
|
|
812
|
-
if (current !== null)
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
915
|
+
if (current !== null) {
|
|
916
|
+
let parsed = null;
|
|
917
|
+
try {
|
|
918
|
+
parsed = JSON.parse(current);
|
|
919
|
+
} catch {
|
|
920
|
+
return current;
|
|
921
|
+
}
|
|
922
|
+
if (parsed !== null && typeof parsed.version === "number" && parsed.version > 1) {
|
|
923
|
+
console.warn(`suppression sidecar ${suppressedFile(root)} declares version ${String(parsed.version)} (newer than 1); not overwritten`);
|
|
924
|
+
return current;
|
|
925
|
+
}
|
|
816
926
|
}
|
|
817
927
|
const names = parseSuppressed(current);
|
|
818
928
|
await task(names);
|
|
@@ -920,6 +1030,20 @@ const EVOLUTION_WRITE_TOOLS = ["memory", "skill_manage"];
|
|
|
920
1030
|
* 0.3.16 (T-4): moved here from skill-store.ts so drift-signals (pure, no IO)
|
|
921
1031
|
* can reference it without importing the skill-store module. */
|
|
922
1032
|
const AUTHORING_DESCRIPTION_BAR = 60;
|
|
1033
|
+
/** V27 G2.4: the largest millisecond delay a timer accepts. `AbortSignal.timeout`
|
|
1034
|
+
* (and `setTimeout`) coerce anything larger to 1ms after a Node warning, so a
|
|
1035
|
+
* timeout configured above this ceiling silently collapses to "immediately
|
|
1036
|
+
* aborted". The curator's review timeout and the review timeout each carried
|
|
1037
|
+
* their own copy of the literal; the bound is one protocol constant.
|
|
1038
|
+
* (v19 P2-10 corrected the value from 2^32-1 to Node's real 2^31-1 ceiling.) */
|
|
1039
|
+
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
1040
|
+
/** V27 G2.4: the model each review/curation leg defaults to. The policy schema,
|
|
1041
|
+
* the policy resolver and the curator's LLM nomination pass each carried their
|
|
1042
|
+
* own copy of these strings — a deployment that changed the policy default used
|
|
1043
|
+
* to leave the curator passing a different model than the reviews. */
|
|
1044
|
+
const DEFAULT_MEMORY_REVIEW_MODEL = "deepseek-v4-flash";
|
|
1045
|
+
const DEFAULT_SKILL_REVIEW_MODEL = "deepseek-v4-pro";
|
|
1046
|
+
const DEFAULT_CURATOR_MODEL = "deepseek-v4-pro";
|
|
923
1047
|
//#endregion
|
|
924
1048
|
//#region lib/types/gates.js
|
|
925
1049
|
/**
|
|
@@ -987,6 +1111,8 @@ function buildCuratorRunReport(input) {
|
|
|
987
1111
|
archiveCandidates: [...input.archiveCandidates],
|
|
988
1112
|
archived: [...input.archived],
|
|
989
1113
|
failed: [...input.failed],
|
|
1114
|
+
...input.aborted === void 0 ? {} : { aborted: input.aborted },
|
|
1115
|
+
...input.unattributed === void 0 || input.unattributed.length === 0 ? {} : { unattributed: [...input.unattributed] },
|
|
990
1116
|
...input.consolidated === void 0 ? {} : { consolidated: [...input.consolidated] },
|
|
991
1117
|
...input.snapshotPath === void 0 ? {} : { snapshotPath: input.snapshotPath },
|
|
992
1118
|
...input.llmReviewEnabled === void 0 ? {} : { llmReviewEnabled: input.llmReviewEnabled },
|
|
@@ -1008,6 +1134,8 @@ function renderCuratorReportMarkdown(report) {
|
|
|
1008
1134
|
`- **LLM nominations**: ${report.llmNominations.length}`,
|
|
1009
1135
|
`- **Archived**: ${report.archived.length}`,
|
|
1010
1136
|
`- **Failed**: ${report.failed.length}`,
|
|
1137
|
+
...report.aborted === void 0 ? [] : [`- **Aborted**: ${report.aborted}`],
|
|
1138
|
+
...report.unattributed === void 0 || report.unattributed.length === 0 ? [] : [`- **Unattributed errors**: ${report.unattributed.length}`],
|
|
1011
1139
|
...report.snapshotPath === void 0 ? [] : [`- **Snapshot**: ${report.snapshotPath}`],
|
|
1012
1140
|
...report.llmReviewEnabled === void 0 ? [] : [`- **llmReview**: ${report.llmReviewEnabled}`],
|
|
1013
1141
|
...report.nominationsWarnings === void 0 || report.nominationsWarnings.length === 0 ? [] : [`- **Nomination warnings**: ${report.nominationsWarnings.join("; ")}`]
|
|
@@ -1022,6 +1150,7 @@ function renderCuratorReportMarkdown(report) {
|
|
|
1022
1150
|
...lines,
|
|
1023
1151
|
...section("Archived", report.archived.map((item) => `${item.name} (${item.reason})`)),
|
|
1024
1152
|
...section("Failed", report.failed.map((item) => `${item.name}: ${item.reason}`)),
|
|
1153
|
+
...section("Unattributed", report.unattributed ?? []),
|
|
1025
1154
|
...section("Stale candidates", report.staleCandidates),
|
|
1026
1155
|
...section("LLM nominations", report.llmNominations),
|
|
1027
1156
|
""
|
|
@@ -1180,15 +1309,6 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
1180
1309
|
});
|
|
1181
1310
|
result.markStale.push(name);
|
|
1182
1311
|
}
|
|
1183
|
-
} else if (idle < staleAfterDays) {
|
|
1184
|
-
record.state = "active";
|
|
1185
|
-
result.transitions.push({
|
|
1186
|
-
name,
|
|
1187
|
-
from: "stale",
|
|
1188
|
-
to: "active",
|
|
1189
|
-
reason: `recent activity ${Math.round(idle)}d`
|
|
1190
|
-
});
|
|
1191
|
-
result.reactivate.push(name);
|
|
1192
1312
|
} else if (idle >= config.archiveAfterDays) {
|
|
1193
1313
|
record.state = "archived";
|
|
1194
1314
|
record.archived_at = now.toISOString();
|
|
@@ -1199,6 +1319,15 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
1199
1319
|
reason: `idle ${Math.round(idle)}d >= ${config.archiveAfterDays}d`
|
|
1200
1320
|
});
|
|
1201
1321
|
result.archive.push(name);
|
|
1322
|
+
} else if (idle < staleAfterDays) {
|
|
1323
|
+
record.state = "active";
|
|
1324
|
+
result.transitions.push({
|
|
1325
|
+
name,
|
|
1326
|
+
from: "stale",
|
|
1327
|
+
to: "active",
|
|
1328
|
+
reason: `recent activity ${Math.round(idle)}d`
|
|
1329
|
+
});
|
|
1330
|
+
result.reactivate.push(name);
|
|
1202
1331
|
}
|
|
1203
1332
|
}
|
|
1204
1333
|
return result;
|
|
@@ -1215,12 +1344,15 @@ function computeLifecycleTransitions(usage, config, now = /* @__PURE__ */ new Da
|
|
|
1215
1344
|
*
|
|
1216
1345
|
* Usage events (C semantics, rc.73+): `type:'usage'` records are the
|
|
1217
1346
|
* OBSERVATION WINDOW ANCHOR — written once, when the library's first observed
|
|
1218
|
-
* read (`view_count` 0 -> 1) happens.
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1223
|
-
* and `window.opened` pins
|
|
1347
|
+
* read (`view_count` 0 -> 1) happens. The anchor is the durable timeline record
|
|
1348
|
+
* of that moment: the churn-suppression gate itself (`usageObserved()`) reads
|
|
1349
|
+
* the usage SIDECAR's own first-view evidence, since reads were invisible to it
|
|
1350
|
+
* pre-A2 and no sidecar record can reach `view_count > 0` without the same
|
|
1351
|
+
* 0 -> 1 transition. `counts` on the event is a cumulative library-wide
|
|
1352
|
+
* snapshot (skills/views/use/patches) at that moment, and `window.opened` pins
|
|
1353
|
+
* the window start for the timeline. (V27 G3.3: `verify-event-pairing` requires
|
|
1354
|
+
* every persisted type to have a production reader or a declared external
|
|
1355
|
+
* contract — this one is the latter.)
|
|
1224
1356
|
*
|
|
1225
1357
|
* Rotation (rc.71, 007 design): when the active log reaches
|
|
1226
1358
|
* `EVENT_LOG_ROTATE_AT` the older half is split into an archive
|
|
@@ -2233,7 +2365,8 @@ const PATTERNS = [
|
|
|
2233
2365
|
regex: /(?:密钥|凭据|口令|密码|环境变量)[\s\S]{0,30}(?:发送|上传|传输|外传|泄露)[\s\S]{0,30}(?:到|至)\s*(?:https?:\/\/|[\w.-]+\.(?:com|net|org|io|cn|dev|xyz|ru)\b)/
|
|
2234
2366
|
}
|
|
2235
2367
|
];
|
|
2236
|
-
const
|
|
2368
|
+
const INVISIBLE_CHAR_CLASS = "\\u034f\\u200b\\u2060\\u2061\\u2062\\u2063\\u2064\\u206a-\\u206f\\ufeff\\u{e0000}-\\u{e007f}";
|
|
2369
|
+
const ZERO_WIDTH_CHARS = new RegExp(`[${INVISIBLE_CHAR_CLASS}]`, "u");
|
|
2237
2370
|
const ZWJ_OUTSIDE_EMOJI = /(?<!\p{Extended_Pictographic})\u200d(?!\p{Extended_Pictographic})/u;
|
|
2238
2371
|
const TYPOGRAPHY_CHARS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/;
|
|
2239
2372
|
const BIDI_CHARS = /[\u202a-\u202e\u2066-\u2069]/;
|
|
@@ -2280,7 +2413,8 @@ function scanThreats(text, scope = "strict", maxScanChars = 65536, options = NO_
|
|
|
2280
2413
|
});
|
|
2281
2414
|
const normalized = text.normalize("NFKC");
|
|
2282
2415
|
const SPACE_SPLITTERS = /[\u00ad\u061c\u180e\u200c\ufe00-\ufe0f]/gu;
|
|
2283
|
-
const
|
|
2416
|
+
const OBFUSCATION_SPLITTERS = new RegExp(`[${INVISIBLE_CHAR_CLASS}\\u200d]`, "gu");
|
|
2417
|
+
const patternTexts = [normalized.replace(SPACE_SPLITTERS, " ").replace(OBFUSCATION_SPLITTERS, " "), normalized.replace(SPACE_SPLITTERS, "").replace(OBFUSCATION_SPLITTERS, "")];
|
|
2284
2418
|
const windows = [];
|
|
2285
2419
|
for (const patternText of patternTexts) if (patternText.length <= windowSize) windows.push(patternText);
|
|
2286
2420
|
else {
|
|
@@ -2548,25 +2682,25 @@ var MemoryStore = class {
|
|
|
2548
2682
|
limit: this.limitFor(target)
|
|
2549
2683
|
};
|
|
2550
2684
|
}
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2685
|
+
/**
|
|
2686
|
+
* The ONE memory write skeleton (V27 G2.5): oversized read-guard pre-transact,
|
|
2687
|
+
* one transaction over the target path, and the C-01 structured refusal when a
|
|
2688
|
+
* backend never invokes the task. `addChained` and `applyBatchChained` supply
|
|
2689
|
+
* only their own in-transaction core, so the two write paths cannot drift in
|
|
2690
|
+
* their guard order, their missing-file handling or their error text.
|
|
2691
|
+
*
|
|
2692
|
+
* @param target - memory target being written
|
|
2693
|
+
* @param core - the in-transaction read-modify-write for the locked body
|
|
2694
|
+
* @returns the core's result, or the oversized / contract-violation refusal
|
|
2695
|
+
*/
|
|
2696
|
+
async chainedWrite(target, core) {
|
|
2563
2697
|
const refusal = await this.oversizedRefusal(target);
|
|
2564
2698
|
if (refusal) return refusal;
|
|
2565
2699
|
let outcome;
|
|
2566
|
-
await transactIo(this.io,
|
|
2567
|
-
const
|
|
2568
|
-
outcome =
|
|
2569
|
-
return
|
|
2700
|
+
await transactIo(this.io, fileFor(this.root, target), async (current) => {
|
|
2701
|
+
const step = await core(current ?? "");
|
|
2702
|
+
outcome = step.result;
|
|
2703
|
+
return step.write ?? current ?? null;
|
|
2570
2704
|
});
|
|
2571
2705
|
return outcome ?? {
|
|
2572
2706
|
ok: false,
|
|
@@ -2576,6 +2710,19 @@ var MemoryStore = class {
|
|
|
2576
2710
|
limit: this.limitFor(target)
|
|
2577
2711
|
};
|
|
2578
2712
|
}
|
|
2713
|
+
async add(target, facts) {
|
|
2714
|
+
return await this.serial(() => this.addChained(target, facts));
|
|
2715
|
+
}
|
|
2716
|
+
async addChained(target, facts) {
|
|
2717
|
+
if (!facts.trim()) return {
|
|
2718
|
+
ok: false,
|
|
2719
|
+
message: "Content cannot be empty.",
|
|
2720
|
+
entries: [],
|
|
2721
|
+
chars: 0,
|
|
2722
|
+
limit: this.limitFor(target)
|
|
2723
|
+
};
|
|
2724
|
+
return await this.chainedWrite(target, async (raw) => await this.addCore(target, facts, raw));
|
|
2725
|
+
}
|
|
2579
2726
|
/**
|
|
2580
2727
|
* Single-entry add inside the transaction: shared checks (oversized,
|
|
2581
2728
|
* drift, threat) and the content computation. `raw` is the locked view
|
|
@@ -2593,19 +2740,11 @@ var MemoryStore = class {
|
|
|
2593
2740
|
},
|
|
2594
2741
|
write: null
|
|
2595
2742
|
};
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
2602
|
-
entries: [],
|
|
2603
|
-
chars: 0,
|
|
2604
|
-
limit: this.limitFor(target)
|
|
2605
|
-
},
|
|
2606
|
-
write: null
|
|
2607
|
-
};
|
|
2608
|
-
}
|
|
2743
|
+
const refusal = await this.driftRefusal(target, raw);
|
|
2744
|
+
if (refusal) return {
|
|
2745
|
+
result: refusal,
|
|
2746
|
+
write: null
|
|
2747
|
+
};
|
|
2609
2748
|
const threat = this.memoryThreatBlock(content);
|
|
2610
2749
|
if (threat) return {
|
|
2611
2750
|
result: {
|
|
@@ -2661,14 +2800,52 @@ var MemoryStore = class {
|
|
|
2661
2800
|
write: render(next)
|
|
2662
2801
|
};
|
|
2663
2802
|
}
|
|
2664
|
-
/**
|
|
2665
|
-
|
|
2666
|
-
|
|
2803
|
+
/**
|
|
2804
|
+
* The single drift predicate. `raw` is in canonical form when it byte-matches
|
|
2805
|
+
* `render(normalizeEntries(raw))`; anything else means it was edited outside
|
|
2806
|
+
* MemoryStore (empty/`§`-only entries, stray blank lines, leading or trailing
|
|
2807
|
+
* delimiters — structural anomalies the writer would quietly normalize away).
|
|
2808
|
+
* Both write paths derive this from their locked view and `detectDrift` from
|
|
2809
|
+
* a fresh read, so a write and a later read can never disagree about the same
|
|
2810
|
+
* bytes.
|
|
2811
|
+
*
|
|
2812
|
+
* An absent, empty, or whitespace-only body is the "never written" state
|
|
2813
|
+
* (rc.42 audit P1-6): it parses to zero entries, and the canonical form
|
|
2814
|
+
* `'\n'` can never byte-match it, so flagging it would permanently refuse
|
|
2815
|
+
* every write path — including the repairs the model would need to make.
|
|
2816
|
+
* Such files are adopted instead of flagged.
|
|
2817
|
+
*
|
|
2818
|
+
* @param target - memory target whose char limit bounds one parsed entry
|
|
2819
|
+
* @param raw - on-disk body, or `null` when the file does not exist
|
|
2820
|
+
* @returns whether these bytes count as externally drifted
|
|
2821
|
+
*/
|
|
2822
|
+
drifted(target, raw) {
|
|
2823
|
+
if (raw === null || raw.trim() === "") return false;
|
|
2667
2824
|
const entries = normalizeEntries(raw);
|
|
2668
2825
|
const limit = this.limitFor(target);
|
|
2669
2826
|
if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
|
|
2670
2827
|
return render(entries) !== raw;
|
|
2671
2828
|
}
|
|
2829
|
+
/**
|
|
2830
|
+
* Drift refusal for a body already read under the write lock, or `null` when
|
|
2831
|
+
* the body is canonical. Both write paths return this unchanged, so their
|
|
2832
|
+
* refusals stay byte-identical and each carries the same backup.
|
|
2833
|
+
*
|
|
2834
|
+
* @param target - memory target that owns the drifted file
|
|
2835
|
+
* @param raw - locked file body
|
|
2836
|
+
* @returns the refusal to hand back, or `null` to continue writing
|
|
2837
|
+
*/
|
|
2838
|
+
async driftRefusal(target, raw) {
|
|
2839
|
+
if (!this.drifted(target, raw)) return null;
|
|
2840
|
+
const backup = await this.backupFile(target);
|
|
2841
|
+
return {
|
|
2842
|
+
ok: false,
|
|
2843
|
+
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
2844
|
+
entries: [],
|
|
2845
|
+
chars: 0,
|
|
2846
|
+
limit: this.limitFor(target)
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2672
2849
|
async applyBatch(target, operations) {
|
|
2673
2850
|
return await this.serial(() => this.applyBatchChained(target, operations));
|
|
2674
2851
|
}
|
|
@@ -2680,38 +2857,15 @@ var MemoryStore = class {
|
|
|
2680
2857
|
chars: 0,
|
|
2681
2858
|
limit: this.limitFor(target)
|
|
2682
2859
|
};
|
|
2683
|
-
|
|
2684
|
-
const refusal = await this.oversizedRefusal(target);
|
|
2685
|
-
if (refusal) return refusal;
|
|
2686
|
-
let outcome;
|
|
2687
|
-
await transactIo(this.io, path, async (current) => {
|
|
2688
|
-
const core = await this.applyBatchCore(target, operations, current ?? "");
|
|
2689
|
-
outcome = core.result;
|
|
2690
|
-
return core.write ?? current ?? null;
|
|
2691
|
-
});
|
|
2692
|
-
return outcome ?? {
|
|
2693
|
-
ok: false,
|
|
2694
|
-
message: "internal error: the memory transaction did not invoke the task; no write was performed",
|
|
2695
|
-
entries: [],
|
|
2696
|
-
chars: 0,
|
|
2697
|
-
limit: this.limitFor(target)
|
|
2698
|
-
};
|
|
2860
|
+
return await this.chainedWrite(target, async (raw) => await this.applyBatchCore(target, operations, raw));
|
|
2699
2861
|
}
|
|
2700
2862
|
/** Batch RMW inside the transaction. `write: null` = failure/no-op, disk untouched. */
|
|
2701
2863
|
async applyBatchCore(target, operations, raw) {
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
message: `External drift detected in memory file.${backup ? ` A backup was saved to ${basename(backup)}.` : ""} Resolve the drift before retrying.`,
|
|
2708
|
-
entries: [],
|
|
2709
|
-
chars: 0,
|
|
2710
|
-
limit: this.limitFor(target)
|
|
2711
|
-
},
|
|
2712
|
-
write: null
|
|
2713
|
-
};
|
|
2714
|
-
}
|
|
2864
|
+
const refusal = await this.driftRefusal(target, raw);
|
|
2865
|
+
if (refusal) return {
|
|
2866
|
+
result: refusal,
|
|
2867
|
+
write: null
|
|
2868
|
+
};
|
|
2715
2869
|
const entries = [...new Set(normalizeEntries(raw))];
|
|
2716
2870
|
const working = [...entries];
|
|
2717
2871
|
const datePrefix = this.addDatePrefix ? `## ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}\n` : "";
|
|
@@ -2881,27 +3035,17 @@ var MemoryStore = class {
|
|
|
2881
3035
|
return parts.join("\n\n");
|
|
2882
3036
|
}
|
|
2883
3037
|
/**
|
|
2884
|
-
* Detect on-disk drift
|
|
2885
|
-
*
|
|
2886
|
-
*
|
|
2887
|
-
*
|
|
2888
|
-
* edited outside MemoryStore. Purely single-canonical content reaches the
|
|
2889
|
-
* same serialization and returns false, so a normal write is never flagged.
|
|
3038
|
+
* Detect on-disk drift for a caller that holds no locked view: `true` when the
|
|
3039
|
+
* file is not in canonical form, or when its size trips the read guard. The
|
|
3040
|
+
* write paths apply the same predicate (`drifted`) to the body they read under
|
|
3041
|
+
* the lock, so a write and a follow-up read agree about the same bytes.
|
|
2890
3042
|
*
|
|
2891
|
-
*
|
|
2892
|
-
*
|
|
2893
|
-
* `'\n'` can never byte-match it and every write path was permanently
|
|
2894
|
-
* refused with "External drift detected" — including the repairs the model
|
|
2895
|
-
* would need to make. Such files are adopted instead of flagged.
|
|
3043
|
+
* @param target - memory target to inspect
|
|
3044
|
+
* @returns whether the file on disk counts as externally drifted
|
|
2896
3045
|
*/
|
|
2897
3046
|
async detectDrift(target) {
|
|
2898
3047
|
if (await this.oversizedFile(target)) return true;
|
|
2899
|
-
|
|
2900
|
-
if (raw === null || raw.trim() === "") return false;
|
|
2901
|
-
const entries = normalizeEntries(raw);
|
|
2902
|
-
const limit = this.limitFor(target);
|
|
2903
|
-
if (limit > 0 && entries.some((entry) => entry.length > limit)) return true;
|
|
2904
|
-
return render(entries) !== raw;
|
|
3048
|
+
return this.drifted(target, await this.io.readText(fileFor(this.root, target)));
|
|
2905
3049
|
}
|
|
2906
3050
|
};
|
|
2907
3051
|
//#endregion
|
|
@@ -3243,7 +3387,7 @@ const SECRET_PATTERNS = [
|
|
|
3243
3387
|
["google api key", /AIza[0-9A-Za-z_-]{30,}/g],
|
|
3244
3388
|
["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
|
|
3245
3389
|
];
|
|
3246
|
-
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");
|
|
3390
|
+
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");
|
|
3247
3391
|
const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)@/gi;
|
|
3248
3392
|
/**
|
|
3249
3393
|
* Mask credential-shaped text before it crosses a session boundary.
|
|
@@ -3639,28 +3783,28 @@ function skillsRoot(env = process.env) {
|
|
|
3639
3783
|
function resolveSkillsRoot(config = {}) {
|
|
3640
3784
|
return (config.root ?? "").trim() || skillsRoot();
|
|
3641
3785
|
}
|
|
3642
|
-
/** E-7 (v18): every family row reads ONE root key. `root` is
|
|
3643
|
-
* `skillsRoot`
|
|
3644
|
-
*
|
|
3645
|
-
*
|
|
3646
|
-
*
|
|
3647
|
-
*
|
|
3648
|
-
*
|
|
3786
|
+
/** E-7 (v18) → V27 G2.4 (M-08): every family row reads ONE root key. `root` is
|
|
3787
|
+
* canonical; the `skillsRoot` alias was honoured for one minor version and its
|
|
3788
|
+
* window closed at 0.3.65 — it is now two releases past expiry, so this
|
|
3789
|
+
* resolver no longer reads it at all. A deployment that still sets the alias
|
|
3790
|
+
* must fail LOUDLY at load (see {@link assertSkillsRootAliasRetired}): silently
|
|
3791
|
+
* ignoring a config key leaves the deployment pointing at a root nobody reads,
|
|
3792
|
+
* which is the worst form of compatibility.
|
|
3793
|
+
* @param config - the raw plugin config.
|
|
3794
|
+
* @returns the effective root (empty when the key is unset or blank).
|
|
3649
3795
|
*/
|
|
3650
3796
|
function resolveRootConfig(config = {}) {
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
usedDeprecatedAlias: true
|
|
3663
|
-
};
|
|
3797
|
+
return { root: (config.root ?? "").trim() };
|
|
3798
|
+
}
|
|
3799
|
+
/** V27 G2.4 (M-08): the retirement gate for the expired `skillsRoot` alias.
|
|
3800
|
+
* Called at each plugin's load boundary (before the root is resolved), it turns
|
|
3801
|
+
* a stale key into an explicit load error naming the replacement — the
|
|
3802
|
+
* fail-loud form the plan requires instead of a silent no-op.
|
|
3803
|
+
* @param config - the raw plugin config (the alias field stays DECLARED in each
|
|
3804
|
+
* schema so the loader can hand it here instead of dropping it).
|
|
3805
|
+
*/
|
|
3806
|
+
function assertSkillsRootAliasRetired(config = {}) {
|
|
3807
|
+
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)");
|
|
3664
3808
|
}
|
|
3665
3809
|
/**
|
|
3666
3810
|
* Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
|
|
@@ -3714,21 +3858,28 @@ function markerPath(dir, marker) {
|
|
|
3714
3858
|
}
|
|
3715
3859
|
/**
|
|
3716
3860
|
* Shared frontmatter block detection (P3-3 single owner): opening line `---`
|
|
3717
|
-
* and closing line exactly
|
|
3861
|
+
* and closing line exactly `---`. Used by `parseFrontmatter`,
|
|
3718
3862
|
* `frontmatterYamlUnsafeValues` and `normalizeFrontmatter` so the three can
|
|
3719
3863
|
* never disagree about where the block ends (the loose `indexOf('\n---')`
|
|
3720
3864
|
* form matched `\n----` and was replaced by this strict line rule).
|
|
3865
|
+
*
|
|
3866
|
+
* V27 G2.1: both fence lines are matched EXACTLY, tolerating only a trailing
|
|
3867
|
+
* `\r` — the same rule the upstream filesystem catalog uses
|
|
3868
|
+
* (`skill-filesystem.parseFrontmatter`). The former `.trim()` comparison
|
|
3869
|
+
* accepted ` --- `, so an indented fence loaded in the family while the
|
|
3870
|
+
* platform ignored the file: family visibility split from platform visibility,
|
|
3871
|
+
* which is exactly what a strict-YAML frontmatter is supposed to prevent.
|
|
3721
3872
|
*/
|
|
3722
3873
|
function frontmatterBlock(content) {
|
|
3723
3874
|
if (!content.trimStart().startsWith("---")) return null;
|
|
3724
3875
|
const nl = content.includes("\r\n") ? "\r\n" : "\n";
|
|
3725
3876
|
const lines = content.split(nl);
|
|
3726
|
-
if ((lines[0] ?? "").
|
|
3877
|
+
if ((lines[0] ?? "").replace(/\r$/, "") !== "---") return null;
|
|
3727
3878
|
let end = -1;
|
|
3728
3879
|
for (let i = 1; i < lines.length; i++) {
|
|
3729
3880
|
const line = lines[i];
|
|
3730
3881
|
if (line === void 0) continue;
|
|
3731
|
-
if (line.
|
|
3882
|
+
if (line.replace(/\r$/, "") === "---") {
|
|
3732
3883
|
end = i;
|
|
3733
3884
|
break;
|
|
3734
3885
|
}
|
|
@@ -3741,24 +3892,176 @@ function frontmatterBlock(content) {
|
|
|
3741
3892
|
nl
|
|
3742
3893
|
};
|
|
3743
3894
|
}
|
|
3744
|
-
|
|
3895
|
+
/**
|
|
3896
|
+
* Raw-line scan of a frontmatter block: the single owner of "which entries the
|
|
3897
|
+
* strict catalog cannot load". `frontmatterYamlUnsafeValues` publishes it and
|
|
3898
|
+
* `normalizeFrontmatter` decides each rewrite with the same predicate
|
|
3899
|
+
* (`yamlPlainScalarNeedsQuotes`), so the audit verdict and the write path can
|
|
3900
|
+
* never disagree. Only single-line `key: value` entries are judged; a line with
|
|
3901
|
+
* embedded breaks is skipped.
|
|
3902
|
+
*/
|
|
3903
|
+
function unsafeFrontmatterEntries(block, nl) {
|
|
3904
|
+
const found = [];
|
|
3905
|
+
for (const line of block.split(nl)) {
|
|
3906
|
+
if (line.includes("\n") || line.includes("\r")) continue;
|
|
3907
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
3908
|
+
if (!match) continue;
|
|
3909
|
+
const key = match[1];
|
|
3910
|
+
if (key === void 0) continue;
|
|
3911
|
+
const value = (match[2] ?? "").trim();
|
|
3912
|
+
if (yamlPlainScalarNeedsQuotes(value)) found.push({
|
|
3913
|
+
key,
|
|
3914
|
+
value
|
|
3915
|
+
});
|
|
3916
|
+
}
|
|
3917
|
+
return found;
|
|
3918
|
+
}
|
|
3919
|
+
/**
|
|
3920
|
+
* Frontmatter values as the STRICT platform catalog reads them — js-yaml, the
|
|
3921
|
+
* parser `normalizeFrontmatter` also verifies rewrites with — or `null` when
|
|
3922
|
+
* the block is not loadable as a YAML mapping.
|
|
3923
|
+
*
|
|
3924
|
+
* Scalars publish their text (`name`, `description`, `whenToUse` are strings by
|
|
3925
|
+
* contract; a number/boolean-shaped value keeps the text the family always
|
|
3926
|
+
* published), a flow or block sequence publishes its inline `[a, b]` form
|
|
3927
|
+
* (`relatedSkillNames` scans names out of it), and a nested mapping publishes
|
|
3928
|
+
* nothing — no consumer in this family reads one, and a lossy string could be
|
|
3929
|
+
* picked up by a routing field. A string value is trimmed: YAML's block-scalar
|
|
3930
|
+
* chomping appends a newline that the family's single-line routing fields never
|
|
3931
|
+
* carried.
|
|
3932
|
+
*/
|
|
3933
|
+
function strictFrontmatterValues(block) {
|
|
3934
|
+
if (block.trim() === "") return /* @__PURE__ */ new Map();
|
|
3935
|
+
let loaded;
|
|
3936
|
+
try {
|
|
3937
|
+
loaded = load(block);
|
|
3938
|
+
} catch {
|
|
3939
|
+
return null;
|
|
3940
|
+
}
|
|
3941
|
+
if (loaded === null || loaded === void 0) return /* @__PURE__ */ new Map();
|
|
3942
|
+
if (typeof loaded !== "object" || Array.isArray(loaded)) return null;
|
|
3943
|
+
const values = /* @__PURE__ */ new Map();
|
|
3944
|
+
for (const [key, value] of Object.entries(loaded)) {
|
|
3945
|
+
if (typeof value === "string") {
|
|
3946
|
+
values.set(key, value.trim());
|
|
3947
|
+
continue;
|
|
3948
|
+
}
|
|
3949
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
3950
|
+
values.set(key, String(value));
|
|
3951
|
+
continue;
|
|
3952
|
+
}
|
|
3953
|
+
if (Array.isArray(value)) {
|
|
3954
|
+
values.set(key, `[${value.map((item) => String(item)).join(", ")}]`);
|
|
3955
|
+
continue;
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
return values;
|
|
3959
|
+
}
|
|
3960
|
+
/**
|
|
3961
|
+
* Lenient line scan, used only for a block the strict parser rejects: the
|
|
3962
|
+
* family keeps routing a legacy file the platform refuses, and
|
|
3963
|
+
* `catalogInvalid` reports the split instead of hiding it. Values are trimmed
|
|
3964
|
+
* and unquoted exactly as before.
|
|
3965
|
+
*/
|
|
3966
|
+
function lenientFrontmatterValues(block, nl) {
|
|
3967
|
+
const values = /* @__PURE__ */ new Map();
|
|
3968
|
+
const blockLines = block.split(nl);
|
|
3969
|
+
for (let i = 0; i < blockLines.length; i += 1) {
|
|
3970
|
+
const line = blockLines[i] ?? "";
|
|
3971
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
3972
|
+
if (!match) continue;
|
|
3973
|
+
const [, key, value] = match;
|
|
3974
|
+
if (key === void 0 || value === void 0) continue;
|
|
3975
|
+
const header = /^([>|])[+-]?\d*$/.exec(value.trim());
|
|
3976
|
+
if (header !== null) {
|
|
3977
|
+
const fold = header[1] === ">";
|
|
3978
|
+
const parts = [];
|
|
3979
|
+
let scan = i + 1;
|
|
3980
|
+
for (; scan < blockLines.length; scan += 1) {
|
|
3981
|
+
const raw = blockLines[scan] ?? "";
|
|
3982
|
+
if (raw.trim() === "") {
|
|
3983
|
+
parts.push("");
|
|
3984
|
+
continue;
|
|
3985
|
+
}
|
|
3986
|
+
if (!/^\s/.test(raw)) break;
|
|
3987
|
+
parts.push(raw.trim());
|
|
3988
|
+
}
|
|
3989
|
+
while (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
|
|
3990
|
+
let folded = "";
|
|
3991
|
+
for (const part of parts) {
|
|
3992
|
+
if (part === "") {
|
|
3993
|
+
if (folded !== "" && !folded.endsWith("\n")) folded += "\n";
|
|
3994
|
+
continue;
|
|
3995
|
+
}
|
|
3996
|
+
if (folded === "" || folded.endsWith("\n")) folded += part;
|
|
3997
|
+
else folded += ` ${part}`;
|
|
3998
|
+
}
|
|
3999
|
+
values.set(key, (fold ? folded : parts.join("\n")).trim());
|
|
4000
|
+
i = scan - 1;
|
|
4001
|
+
continue;
|
|
4002
|
+
}
|
|
4003
|
+
values.set(key, value.trim().replace(/^["']|["']$/g, ""));
|
|
4004
|
+
}
|
|
4005
|
+
return values;
|
|
4006
|
+
}
|
|
4007
|
+
/**
|
|
4008
|
+
* The single read of a SKILL.md frontmatter block: values, body and every
|
|
4009
|
+
* strict-catalog signal, computed once. `null` only when the file has no
|
|
4010
|
+
* frontmatter block; the body may be empty. {@link parseFrontmatter} and
|
|
4011
|
+
* {@link frontmatterCatalogInvalid} are its two projections.
|
|
4012
|
+
*
|
|
4013
|
+
* @param content - the SKILL.md text.
|
|
4014
|
+
* @returns the block read, or `null` when there is no block.
|
|
4015
|
+
*/
|
|
4016
|
+
function readFrontmatterBlock(content) {
|
|
3745
4017
|
const found = frontmatterBlock(content);
|
|
3746
4018
|
if (!found) return null;
|
|
3747
4019
|
const body = found.lines.slice(found.end + 1).join(found.nl).trim();
|
|
3748
|
-
|
|
4020
|
+
const strict = strictFrontmatterValues(found.block);
|
|
4021
|
+
const values = strict ?? lenientFrontmatterValues(found.block, found.nl);
|
|
3749
4022
|
const frontmatter = {};
|
|
3750
|
-
for (const
|
|
3751
|
-
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
3752
|
-
if (match) {
|
|
3753
|
-
const [, key, value] = match;
|
|
3754
|
-
if (key && value !== void 0) frontmatter[key] = value.trim().replace(/^["']|["']$/g, "");
|
|
3755
|
-
}
|
|
3756
|
-
}
|
|
4023
|
+
for (const [key, value] of values) frontmatter[key] = value;
|
|
3757
4024
|
return {
|
|
3758
4025
|
frontmatter,
|
|
3759
|
-
body
|
|
4026
|
+
body,
|
|
4027
|
+
unsafeValues: unsafeFrontmatterEntries(found.block, found.nl),
|
|
4028
|
+
strictFailed: strict === null
|
|
4029
|
+
};
|
|
4030
|
+
}
|
|
4031
|
+
/**
|
|
4032
|
+
* Parse a SKILL.md: its frontmatter values and body, or `null` when the file
|
|
4033
|
+
* has no frontmatter block or no body. Every consumer of frontmatter values
|
|
4034
|
+
* goes through here — the write path's validation, `list()`'s published
|
|
4035
|
+
* description, `relatedSkillNames` and the audit — so all of them read the same
|
|
4036
|
+
* bytes the same way.
|
|
4037
|
+
*
|
|
4038
|
+
* @param content - the SKILL.md text.
|
|
4039
|
+
* @returns the read, or `null` when there is no block or no body.
|
|
4040
|
+
*/
|
|
4041
|
+
function parseFrontmatter(content) {
|
|
4042
|
+
const read = readFrontmatterBlock(content);
|
|
4043
|
+
if (!read || !read.body) return null;
|
|
4044
|
+
return {
|
|
4045
|
+
frontmatter: read.frontmatter,
|
|
4046
|
+
body: read.body,
|
|
4047
|
+
unsafeValues: read.unsafeValues,
|
|
4048
|
+
catalogInvalid: read.strictFailed || read.unsafeValues.length > 0
|
|
3760
4049
|
};
|
|
3761
4050
|
}
|
|
4051
|
+
/**
|
|
4052
|
+
* Whether this file's frontmatter is valid as written for the strict platform
|
|
4053
|
+
* catalog (see `FrontmatterRead.catalogInvalid`). Body-independent (a body-less
|
|
4054
|
+
* file is still judged), and derived from the same read as `parseFrontmatter` —
|
|
4055
|
+
* so the audit's verdict and the values the family publishes for one file can
|
|
4056
|
+
* never disagree (V27 G2.1).
|
|
4057
|
+
*
|
|
4058
|
+
* @param content - the SKILL.md text.
|
|
4059
|
+
* @returns `true` when the strict parser rejects the block or an unquoted value would read as something else.
|
|
4060
|
+
*/
|
|
4061
|
+
function frontmatterCatalogInvalid(content) {
|
|
4062
|
+
const read = readFrontmatterBlock(content);
|
|
4063
|
+
return read === null ? false : read.strictFailed || read.unsafeValues.length > 0;
|
|
4064
|
+
}
|
|
3762
4065
|
/** YAML plain-scalar hazards that make an UNQUOTED frontmatter value
|
|
3763
4066
|
* unloadable to the platform catalog (strict YAML parser): `: ` (mapping
|
|
3764
4067
|
* separator), ` #` (comment start), a trailing `:` (a mapping marker),
|
|
@@ -3777,6 +4080,7 @@ function yamlPlainScalarNeedsQuotes(value) {
|
|
|
3777
4080
|
if (value.length === 0) return false;
|
|
3778
4081
|
if (value.length >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) return false;
|
|
3779
4082
|
if (/^\[.*\]$/.test(value) || /^\{.*\}$/.test(value)) return false;
|
|
4083
|
+
if (/^[>|][+-]?\d*$/.test(value)) return false;
|
|
3780
4084
|
if (value.includes(": ")) return true;
|
|
3781
4085
|
if (value.includes(" #")) return true;
|
|
3782
4086
|
if (value.endsWith(":")) return true;
|
|
@@ -3788,24 +4092,13 @@ function yamlPlainScalarNeedsQuotes(value) {
|
|
|
3788
4092
|
* YAML-unsafe for the strict platform catalog. Operates on the ORIGINAL line
|
|
3789
4093
|
* value (quotes included), so a value already wrapped by
|
|
3790
4094
|
* `normalizeFrontmatter` is never re-flagged — one source with the write
|
|
3791
|
-
* path.
|
|
4095
|
+
* path. V27 G2.1: delegates to the shared scan, which
|
|
4096
|
+
* `parseFrontmatter(...).catalogInvalid` also uses, so the audit view and the
|
|
4097
|
+
* read view of one file can never disagree. Independent of the body: a
|
|
4098
|
+
* body-less file is still reported here. */
|
|
3792
4099
|
function frontmatterYamlUnsafeValues(content) {
|
|
3793
|
-
const found = [];
|
|
3794
4100
|
const block = frontmatterBlock(content);
|
|
3795
|
-
|
|
3796
|
-
for (const line of block.block.split(block.nl)) {
|
|
3797
|
-
if (line.includes("\n") || line.includes("\r")) continue;
|
|
3798
|
-
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
3799
|
-
if (!match) continue;
|
|
3800
|
-
const key = match[1];
|
|
3801
|
-
const value = (match[2] ?? "").trim();
|
|
3802
|
-
if (key === void 0) continue;
|
|
3803
|
-
if (yamlPlainScalarNeedsQuotes(value)) found.push({
|
|
3804
|
-
key,
|
|
3805
|
-
value
|
|
3806
|
-
});
|
|
3807
|
-
}
|
|
3808
|
-
return found;
|
|
4101
|
+
return block === null ? [] : unsafeFrontmatterEntries(block.block, block.nl);
|
|
3809
4102
|
}
|
|
3810
4103
|
/**
|
|
3811
4104
|
* Normalize a SKILL.md frontmatter block into catalog-loadable YAML: values
|
|
@@ -3916,7 +4209,7 @@ function relatedSkillNames(content, exclude) {
|
|
|
3916
4209
|
}
|
|
3917
4210
|
function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS) {
|
|
3918
4211
|
const parsed = parseFrontmatter(content);
|
|
3919
|
-
if (!parsed) return "SKILL.md must start
|
|
4212
|
+
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.";
|
|
3920
4213
|
if (!parsed.frontmatter.name) return "Frontmatter must include a name field.";
|
|
3921
4214
|
if (!SKILL_NAME_RE.test(parsed.frontmatter.name)) return `Invalid skill name "${parsed.frontmatter.name}" — use lowercase letters, digits, and hyphens.`;
|
|
3922
4215
|
if (parsed.frontmatter.name.length > limits.maxNameLength) return `Skill name exceeds ${limits.maxNameLength} characters.`;
|
|
@@ -4804,6 +5097,8 @@ var SkillLibrary = class {
|
|
|
4804
5097
|
},
|
|
4805
5098
|
write: null
|
|
4806
5099
|
};
|
|
5100
|
+
const anchorCount = oldString === "" ? 0 : md.split(oldString).length - 1;
|
|
5101
|
+
const patchNote = !replaceAll && anchorCount > 1 ? ` Replaced the FIRST of ${anchorCount} occurrences; pass replace_all=true to change every one.` : "";
|
|
4807
5102
|
let writeContent = patched;
|
|
4808
5103
|
let normalizedFields;
|
|
4809
5104
|
if (target === skillMd) {
|
|
@@ -4871,7 +5166,7 @@ var SkillLibrary = class {
|
|
|
4871
5166
|
return {
|
|
4872
5167
|
result: {
|
|
4873
5168
|
ok: true,
|
|
4874
|
-
message: `Skill "${name}" patched (${patchLabel})
|
|
5169
|
+
message: `Skill "${name}" patched (${patchLabel}).${patchNote}`,
|
|
4875
5170
|
path: dir,
|
|
4876
5171
|
...normalizedFields ? { normalizedFrontmatterFields: normalizedFields } : {}
|
|
4877
5172
|
},
|
|
@@ -5085,6 +5380,9 @@ var SkillLibrary = class {
|
|
|
5085
5380
|
try {
|
|
5086
5381
|
await this.io.writeText(join(dest, ".archive-reason"), `${(/* @__PURE__ */ new Date()).toISOString()}: ${reason}\n`);
|
|
5087
5382
|
} catch {}
|
|
5383
|
+
try {
|
|
5384
|
+
await this.io.writeText(join(dest, ".archive-name"), `${name}\n`);
|
|
5385
|
+
} catch {}
|
|
5088
5386
|
await this.audit(name, "archive", md, null, reason);
|
|
5089
5387
|
this.notifyMutation({
|
|
5090
5388
|
action: "archive",
|
|
@@ -5211,10 +5509,12 @@ var SkillLibrary = class {
|
|
|
5211
5509
|
};
|
|
5212
5510
|
const writes = [];
|
|
5213
5511
|
for (const reference of referenceWrites) {
|
|
5214
|
-
const
|
|
5512
|
+
const previous = await this.io.readText(reference.target).catch(() => null);
|
|
5513
|
+
const base = previous?.trimEnd() ?? "";
|
|
5215
5514
|
writes.push({
|
|
5216
5515
|
target: reference.target,
|
|
5217
|
-
content: base === "" ? reference.content : `${base}\n\n${reference.content}
|
|
5516
|
+
content: base === "" ? reference.content : `${base}\n\n${reference.content}`,
|
|
5517
|
+
expected: previous
|
|
5218
5518
|
});
|
|
5219
5519
|
}
|
|
5220
5520
|
if (mode === "append") {
|
|
@@ -5226,7 +5526,8 @@ var SkillLibrary = class {
|
|
|
5226
5526
|
};
|
|
5227
5527
|
writes.push({
|
|
5228
5528
|
target: join(targetDir, "SKILL.md"),
|
|
5229
|
-
content: merged
|
|
5529
|
+
content: merged,
|
|
5530
|
+
expected: freshTargetMd
|
|
5230
5531
|
});
|
|
5231
5532
|
} else {
|
|
5232
5533
|
const pointerLines = normalizedSources.map((source) => `\n${POINTER_LINE_PREFIX}${source}.md`).join("");
|
|
@@ -5238,7 +5539,8 @@ var SkillLibrary = class {
|
|
|
5238
5539
|
};
|
|
5239
5540
|
writes.push({
|
|
5240
5541
|
target: join(targetDir, "SKILL.md"),
|
|
5241
|
-
content: extended
|
|
5542
|
+
content: extended,
|
|
5543
|
+
expected: freshTargetMd
|
|
5242
5544
|
});
|
|
5243
5545
|
}
|
|
5244
5546
|
return await this.applyTreeChange({
|
|
@@ -5306,6 +5608,11 @@ var SkillLibrary = class {
|
|
|
5306
5608
|
message: `Restructure exceeds 5 moves.`
|
|
5307
5609
|
};
|
|
5308
5610
|
for (const move of moves) {
|
|
5611
|
+
const raw = move;
|
|
5612
|
+
if (raw === null || typeof raw !== "object") return {
|
|
5613
|
+
ok: false,
|
|
5614
|
+
message: "Every restructure move must be an object with a heading."
|
|
5615
|
+
};
|
|
5309
5616
|
if (typeof move.heading !== "string" || !move.heading.trim()) return {
|
|
5310
5617
|
ok: false,
|
|
5311
5618
|
message: "Every restructure move needs a non-empty heading."
|
|
@@ -5359,15 +5666,18 @@ var SkillLibrary = class {
|
|
|
5359
5666
|
const writes = [];
|
|
5360
5667
|
for (const entry of byRel.values()) {
|
|
5361
5668
|
const target = join(dir, ...entry.rel.split("/"));
|
|
5362
|
-
const
|
|
5669
|
+
const previous = await this.io.readText(target).catch(() => null);
|
|
5670
|
+
const base = previous?.trimEnd() ?? "";
|
|
5363
5671
|
writes.push({
|
|
5364
5672
|
target,
|
|
5365
|
-
content: base === "" ? entry.texts.join("\n\n") : `${base}\n\n${entry.texts.join("\n\n")}
|
|
5673
|
+
content: base === "" ? entry.texts.join("\n\n") : `${base}\n\n${entry.texts.join("\n\n")}`,
|
|
5674
|
+
expected: previous
|
|
5366
5675
|
});
|
|
5367
5676
|
}
|
|
5368
5677
|
writes.push({
|
|
5369
5678
|
target: join(dir, "SKILL.md"),
|
|
5370
|
-
content: finalMd
|
|
5679
|
+
content: finalMd,
|
|
5680
|
+
expected: md
|
|
5371
5681
|
});
|
|
5372
5682
|
const result = await this.applyTreeChange({
|
|
5373
5683
|
name,
|
|
@@ -5433,7 +5743,8 @@ var SkillLibrary = class {
|
|
|
5433
5743
|
landing.push({
|
|
5434
5744
|
target: write.target,
|
|
5435
5745
|
content: write.content,
|
|
5436
|
-
previous
|
|
5746
|
+
previous,
|
|
5747
|
+
expected: write.expected
|
|
5437
5748
|
});
|
|
5438
5749
|
}
|
|
5439
5750
|
const written = [];
|
|
@@ -5442,9 +5753,10 @@ var SkillLibrary = class {
|
|
|
5442
5753
|
for (const entry of landing) {
|
|
5443
5754
|
try {
|
|
5444
5755
|
if (this.transact) {
|
|
5756
|
+
const baseline = entry.expected === void 0 ? entry.previous : entry.expected;
|
|
5445
5757
|
const drift = { seen: false };
|
|
5446
5758
|
await this.transact(this.io, entry.target, (current) => {
|
|
5447
|
-
if (current !==
|
|
5759
|
+
if (current !== baseline) {
|
|
5448
5760
|
drift.seen = true;
|
|
5449
5761
|
return current;
|
|
5450
5762
|
}
|
|
@@ -5462,10 +5774,21 @@ var SkillLibrary = class {
|
|
|
5462
5774
|
});
|
|
5463
5775
|
}
|
|
5464
5776
|
} catch (error) {
|
|
5465
|
-
|
|
5777
|
+
const stuck = [];
|
|
5778
|
+
for (const entry of written.reverse()) try {
|
|
5779
|
+
if (entry.previous === null) await this.io.remove(entry.target);
|
|
5780
|
+
else await this.io.writeText(entry.target, entry.previous);
|
|
5781
|
+
} catch {
|
|
5782
|
+
stuck.push(entry.target);
|
|
5783
|
+
}
|
|
5784
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
5785
|
+
if (stuck.length === 0) return {
|
|
5786
|
+
ok: false,
|
|
5787
|
+
message: `Tree change failed and was rolled back: ${reason}`
|
|
5788
|
+
};
|
|
5466
5789
|
return {
|
|
5467
5790
|
ok: false,
|
|
5468
|
-
message: `Tree change failed
|
|
5791
|
+
message: `Tree change failed: ${reason}. The rollback could not restore ${stuck.join(", ")} — recover those targets from the .backups snapshot (or re-apply the change).`
|
|
5469
5792
|
};
|
|
5470
5793
|
}
|
|
5471
5794
|
await this.audit(name, plan.auditAction, md, landing.find((entry) => entry.target.split(/[\\/]/).pop() === "SKILL.md")?.content ?? md, plan.auditSummary);
|
|
@@ -5509,14 +5832,33 @@ var SkillLibrary = class {
|
|
|
5509
5832
|
}
|
|
5510
5833
|
const candidates = entries.filter((entry) => entry === name || entry.startsWith(`${name}-`)).sort().reverse();
|
|
5511
5834
|
let chosen;
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5835
|
+
const unreadable = [];
|
|
5836
|
+
for (const candidate of candidates) {
|
|
5837
|
+
if ((await this.io.readText(join(archiveRoot, candidate, ".archive-name")).catch(() => null))?.trim() === name) {
|
|
5838
|
+
chosen = candidate;
|
|
5839
|
+
break;
|
|
5840
|
+
}
|
|
5841
|
+
const parsed = parseFrontmatter(await this.io.readText(join(archiveRoot, candidate, "SKILL.md")).catch(() => null) ?? "");
|
|
5842
|
+
if (parsed?.frontmatter.name === name) {
|
|
5843
|
+
chosen = candidate;
|
|
5844
|
+
break;
|
|
5845
|
+
}
|
|
5846
|
+
if (parsed === null && candidate === name) {
|
|
5847
|
+
chosen = candidate;
|
|
5848
|
+
break;
|
|
5849
|
+
}
|
|
5850
|
+
if (parsed === null) unreadable.push(candidate);
|
|
5851
|
+
}
|
|
5852
|
+
if (!chosen) {
|
|
5853
|
+
if (unreadable.length === 0) return {
|
|
5854
|
+
ok: false,
|
|
5855
|
+
message: `Skill "${name}" is not in .archive.`
|
|
5856
|
+
};
|
|
5857
|
+
return {
|
|
5858
|
+
ok: false,
|
|
5859
|
+
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.`
|
|
5860
|
+
};
|
|
5515
5861
|
}
|
|
5516
|
-
if (!chosen) return {
|
|
5517
|
-
ok: false,
|
|
5518
|
-
message: `Skill "${name}" is not in .archive.`
|
|
5519
|
-
};
|
|
5520
5862
|
const source = join(archiveRoot, chosen);
|
|
5521
5863
|
if (this.io.isSymlink) {
|
|
5522
5864
|
if (await this.io.isSymlink(source) === true) return {
|
|
@@ -5534,7 +5876,7 @@ var SkillLibrary = class {
|
|
|
5534
5876
|
message: `Restore of "${name}" from .archive failed: ${moveFailure}`
|
|
5535
5877
|
};
|
|
5536
5878
|
await this.deleteStrandedLocks(dest);
|
|
5537
|
-
if (await this.io.exists(join(dest,
|
|
5879
|
+
for (const marker of [".archive-reason", ".archive-name"]) if (await this.io.exists(join(dest, marker))) await this.io.remove(join(dest, marker));
|
|
5538
5880
|
await this.audit(name, "restore", null, await this.io.readText(join(dest, "SKILL.md")).catch(() => null), `restored from ${source}`);
|
|
5539
5881
|
this.notifyMutation({
|
|
5540
5882
|
action: "restore",
|
|
@@ -5707,7 +6049,13 @@ var SkillLibrary = class {
|
|
|
5707
6049
|
while (await this.io.exists(dest)) dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
|
|
5708
6050
|
try {
|
|
5709
6051
|
const names = await listNames(this.root, this.io);
|
|
5710
|
-
const
|
|
6052
|
+
const skipped = [];
|
|
6053
|
+
const copyable = [];
|
|
6054
|
+
for (const name of names) if (await this.hasWriteLock(this.dirOf(name))) {
|
|
6055
|
+
console.warn(`skill-store: snapshot skipped "${name}" — a byte-writer holds its write lock; the skill is recorded as skipped in the manifest`);
|
|
6056
|
+
skipped.push(name);
|
|
6057
|
+
} else copyable.push(name);
|
|
6058
|
+
const copyFailure = (await Promise.allSettled(copyable.map(async (name) => {
|
|
5711
6059
|
await this.io.copy(this.dirOf(name), join(dest, name));
|
|
5712
6060
|
}))).find((result) => result.status === "rejected");
|
|
5713
6061
|
if (copyFailure) throw copyFailure.reason;
|
|
@@ -5732,7 +6080,8 @@ var SkillLibrary = class {
|
|
|
5732
6080
|
await this.io.writeText(join(dest, "manifest.json"), JSON.stringify({
|
|
5733
6081
|
reason,
|
|
5734
6082
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5735
|
-
skills:
|
|
6083
|
+
skills: copyable,
|
|
6084
|
+
skipped,
|
|
5736
6085
|
sidecars,
|
|
5737
6086
|
hasArchive,
|
|
5738
6087
|
extras: extraNames
|
|
@@ -5745,6 +6094,45 @@ var SkillLibrary = class {
|
|
|
5745
6094
|
return dest;
|
|
5746
6095
|
}
|
|
5747
6096
|
/** Read and normalize a snapshot manifest; null when the file is missing or unparsable. */
|
|
6097
|
+
/**
|
|
6098
|
+
* V26-03 (v25/v26): sanitize the manifest's `skipped` list before it can
|
|
6099
|
+
* reach a user-visible restore message. Entries must pass the same name
|
|
6100
|
+
* gate as snapshot entries (a corrupted or hand-edited manifest cannot
|
|
6101
|
+
* inject arbitrary text into the result), bounded to 50 entries of at most
|
|
6102
|
+
* 64 chars each (the name-rule maximum — real skill names always fit).
|
|
6103
|
+
*/
|
|
6104
|
+
sanitizeSkippedNames(raw) {
|
|
6105
|
+
if (!Array.isArray(raw)) return [];
|
|
6106
|
+
const out = [];
|
|
6107
|
+
for (const entry of raw) {
|
|
6108
|
+
if (typeof entry !== "string") continue;
|
|
6109
|
+
if (!this.safeSnapshotEntryName(entry)) continue;
|
|
6110
|
+
out.push(entry.length > 64 ? entry.slice(0, 64) : entry);
|
|
6111
|
+
if (out.length >= 50) break;
|
|
6112
|
+
}
|
|
6113
|
+
return out;
|
|
6114
|
+
}
|
|
6115
|
+
/**
|
|
6116
|
+
* V27 G0.4 (core-a-01): the per-entry gate `skipped` already has. A corrupted
|
|
6117
|
+
* or hand-edited manifest could carry `extras: [123]`: the array check passed,
|
|
6118
|
+
* `SNAPSHOT_EXTRA_NAME_RE.test(123)` coerced the number to the string "123"
|
|
6119
|
+
* and matched, and the value then threw `TypeError` inside `path.join` — which
|
|
6120
|
+
* `readSnapshotExtras` reached only AFTER a whole-tree restore had committed.
|
|
6121
|
+
* Extras are path components under `extras/`, so they take the entry gate too;
|
|
6122
|
+
* bounded like `skipped` so a hostile manifest cannot grow the read set.
|
|
6123
|
+
*/
|
|
6124
|
+
sanitizeExtraNames(raw) {
|
|
6125
|
+
if (!Array.isArray(raw)) return [];
|
|
6126
|
+
const out = [];
|
|
6127
|
+
for (const entry of raw) {
|
|
6128
|
+
if (typeof entry !== "string") continue;
|
|
6129
|
+
if (!SNAPSHOT_EXTRA_NAME_RE.test(entry)) continue;
|
|
6130
|
+
if (!this.safeSnapshotEntryName(entry)) continue;
|
|
6131
|
+
out.push(entry);
|
|
6132
|
+
if (out.length >= 50) break;
|
|
6133
|
+
}
|
|
6134
|
+
return out;
|
|
6135
|
+
}
|
|
5748
6136
|
async readSnapshotManifest(path) {
|
|
5749
6137
|
const raw = await this.io.readText(join(path, "manifest.json"));
|
|
5750
6138
|
if (raw === null) return null;
|
|
@@ -5755,9 +6143,10 @@ var SkillLibrary = class {
|
|
|
5755
6143
|
reason: typeof manifest.reason === "string" ? manifest.reason : "",
|
|
5756
6144
|
createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "",
|
|
5757
6145
|
skills: manifest.skills,
|
|
6146
|
+
skipped: this.sanitizeSkippedNames(manifest.skipped),
|
|
5758
6147
|
sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
|
|
5759
6148
|
...typeof manifest.hasArchive === "boolean" ? { hasArchive: manifest.hasArchive } : {},
|
|
5760
|
-
extras:
|
|
6149
|
+
extras: this.sanitizeExtraNames(manifest.extras)
|
|
5761
6150
|
};
|
|
5762
6151
|
} catch {
|
|
5763
6152
|
return null;
|
|
@@ -5831,6 +6220,7 @@ var SkillLibrary = class {
|
|
|
5831
6220
|
message: "No skill snapshot available."
|
|
5832
6221
|
};
|
|
5833
6222
|
const preRollbackPath = await this.snapshotAll("pre-rollback", extras);
|
|
6223
|
+
const snapshotExtras = await this.readSnapshotExtras(latest.path);
|
|
5834
6224
|
try {
|
|
5835
6225
|
await this.restoreSnapshotIntoRoot(latest.path);
|
|
5836
6226
|
} catch (error) {
|
|
@@ -5848,7 +6238,6 @@ var SkillLibrary = class {
|
|
|
5848
6238
|
};
|
|
5849
6239
|
}
|
|
5850
6240
|
}
|
|
5851
|
-
const snapshotExtras = await this.readSnapshotExtras(latest.path);
|
|
5852
6241
|
this.notifyMutation({
|
|
5853
6242
|
action: "restore",
|
|
5854
6243
|
name: "snapshot"
|
|
@@ -5870,6 +6259,7 @@ var SkillLibrary = class {
|
|
|
5870
6259
|
const manifest = await this.readSnapshotManifest(snapshotPath);
|
|
5871
6260
|
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`);
|
|
5872
6261
|
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`);
|
|
6262
|
+
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`);
|
|
5873
6263
|
if (manifest.skills.length === 0) {
|
|
5874
6264
|
const snapshotEntries = await this.io.list(snapshotPath);
|
|
5875
6265
|
const declared = new Set([
|
|
@@ -5923,4 +6313,4 @@ var SkillLibrary = class {
|
|
|
5923
6313
|
}
|
|
5924
6314
|
};
|
|
5925
6315
|
//#endregion
|
|
5926
|
-
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, frontmatterYamlUnsafeValues, getRecord, isProcessAlive, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadSuppressedNames, loadUsage, makeSerialQueue, markAgentCreated, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, 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 };
|
|
6316
|
+
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, frontmatterYamlUnsafeValues, 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 };
|