@lifeaitools/rdc-skills 0.35.15 → 0.35.17
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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rdc",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.17",
|
|
4
4
|
"description": "RDC typed-agent dispatch skill suite for Claude Code — plan, build, review, overnight unattended builds with work-item tracking and TDD enforcement.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "LIFEAI",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lifeaitools/rdc-skills",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.17",
|
|
4
4
|
"description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"rdc-design": "node scripts/rdc-design-cli.mjs",
|
|
46
46
|
"test:hooks": "node scripts/test-rdc-hooks.mjs",
|
|
47
47
|
"test:truth-gate": "node tests/run-evidence-gate.test.mjs && node tests/work-item-exit-gate-l2.test.mjs && node tests/work-item-exit-gate-l3.test.mjs && node tests/require-work-item-on-commit.test.mjs && node tests/harness-gates.test.mjs && node tests/foreground-process-gate.test.mjs",
|
|
48
|
-
"test:acceptance": "node tests/acceptance.test.mjs && node tests/install-rdc-skills.test.mjs && node tests/help-surface.test.mjs && node tests/manifest-contract-fields.test.mjs && node tests/plugin-namespace-names.test.mjs && node tests/skill-test-matrix.test.mjs && node tests/completion-gate-supervisor-admission.test.mjs && node tests/curl-surface.test.mjs && node tests/clauth-plugin-postinstall.test.mjs",
|
|
48
|
+
"test:acceptance": "node tests/acceptance.test.mjs && node tests/install-rdc-skills.test.mjs && node tests/help-surface.test.mjs && node tests/manifest-contract-fields.test.mjs && node tests/plugin-namespace-names.test.mjs && node tests/skill-test-matrix.test.mjs && node tests/completion-gate-supervisor-admission.test.mjs && node tests/curl-surface.test.mjs && node tests/clauth-plugin-postinstall.test.mjs && node tests/install-duplicate-purge.test.mjs",
|
|
49
49
|
"acceptance": "node scripts/acceptance.mjs --changed",
|
|
50
50
|
"test:mcp": "node tests/mcp.test.mjs",
|
|
51
51
|
"test:mcp:remote": "node tests/mcp.test.mjs --remote",
|
|
@@ -369,11 +369,162 @@ function buildPluginCache(cacheDir, version, gitSha) {
|
|
|
369
369
|
}
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
// ── Is this entry a duplicate of a skill WE ship? ─────────────────────────────
|
|
373
|
+
//
|
|
374
|
+
// THE PREDICATE THIS REPLACES WAS DEAD. It removed an entry only when its
|
|
375
|
+
// frontmatter name startsWith('rdc:'). Skills were later renamed to bare names
|
|
376
|
+
// (`name: build`, not `name: rdc:build`) — the same rename the marketplace-sync
|
|
377
|
+
// comment below refers to when it describes "/rdc:rdc:plan" appearing beside
|
|
378
|
+
// "/rdc:plan". Measured 2026-08-30: 0 of 44 shipped skills carry an rdc: prefix.
|
|
379
|
+
// So the purge matched nothing, removed nothing, and reported 0 while duplicate
|
|
380
|
+
// registrations kept accumulating. Nothing failed loudly, because a purge that
|
|
381
|
+
// finds nothing and a purge that cannot match anything print the same number.
|
|
382
|
+
//
|
|
383
|
+
// The replacement never keys on a prefix. It asks three independent questions,
|
|
384
|
+
// any one of which is sufficient:
|
|
385
|
+
//
|
|
386
|
+
// 1. LEGACY NAME — the old rdc: prefix, kept so pre-rename installs still get
|
|
387
|
+
// cleaned. Dropping it would strand exactly the machines this function was
|
|
388
|
+
// written for.
|
|
389
|
+
// 2. SYMLINK INTO OUR OWN PACKAGE — if the entry resolves to a file inside this
|
|
390
|
+
// package's skills/ tree, it IS our file, wearing a second name. That is
|
|
391
|
+
// unambiguous and survives any future rename.
|
|
392
|
+
// 3. SHIPPED NAME + OUR MARKER — the name matches one we ship AND the body
|
|
393
|
+
// carries the output-contract line every rdc skill opens with. Name alone
|
|
394
|
+
// is deliberately NOT enough: a user skill legitimately called "build" must
|
|
395
|
+
// survive, and on this machine ~/.claude/skills holds clauth, coolify-*,
|
|
396
|
+
// leaflet-maps and notebooklm — none of which may ever be touched.
|
|
397
|
+
//
|
|
398
|
+
// Signal 3 covers 43 of 44 shipped skills; behavior-audit lacks the marker and
|
|
399
|
+
// is caught by signal 2 when symlinked. A skill that is neither symlinked nor
|
|
400
|
+
// marked is deliberately left alone — under-removing is recoverable, deleting a
|
|
401
|
+
// user's own work is not.
|
|
402
|
+
let _shippedSkillNames = null;
|
|
403
|
+
function shippedSkillNames() {
|
|
404
|
+
if (_shippedSkillNames) return _shippedSkillNames;
|
|
405
|
+
_shippedSkillNames = new Set();
|
|
406
|
+
const dir = path.join(repoRoot, 'skills');
|
|
407
|
+
try {
|
|
408
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
409
|
+
if (!e.isDirectory()) continue;
|
|
410
|
+
_shippedSkillNames.add(e.name);
|
|
411
|
+
for (const sf of ['SKILL.md', 'skill.md']) {
|
|
412
|
+
const p = path.join(dir, e.name, sf);
|
|
413
|
+
if (fs.existsSync(p)) {
|
|
414
|
+
const fm = readFrontmatter(p);
|
|
415
|
+
if (fm.name) _shippedSkillNames.add(fm.name);
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
} catch { /* no skills/ dir — handled by the emptiness check below */ }
|
|
421
|
+
// H5: an EMPTY set silently collapses isRdcSkillDuplicate back to the legacy
|
|
422
|
+
// rdc: prefix test — i.e. back to the exact dead predicate this whole change
|
|
423
|
+
// exists to fix, reporting "0 removed", indistinguishable from success. Say so
|
|
424
|
+
// rather than returning quietly; that is the same reasoning applied to the
|
|
425
|
+
// post-purge verifier.
|
|
426
|
+
if (_shippedSkillNames.size === 0) {
|
|
427
|
+
console.warn(' ! shippedSkillNames() is EMPTY — skills/ missing or unreadable. '
|
|
428
|
+
+ 'Duplicate detection is degraded to the legacy rdc: prefix only.');
|
|
429
|
+
}
|
|
430
|
+
return _shippedSkillNames;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const RDC_SKILL_MARKER = 'guides/output-contract.md';
|
|
434
|
+
|
|
435
|
+
function resolvesInsideOurSkills(candidate) {
|
|
436
|
+
try {
|
|
437
|
+
const real = fs.realpathSync(candidate);
|
|
438
|
+
const ours = fs.realpathSync(path.join(repoRoot, 'skills'));
|
|
439
|
+
const rel = path.relative(ours, real);
|
|
440
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
441
|
+
} catch {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Is this entry PROVABLY ours, as opposed to merely suspect?
|
|
448
|
+
*
|
|
449
|
+
* Only two signals are unambiguous:
|
|
450
|
+
* 1. the legacy `rdc:` frontmatter prefix — no user names a skill that
|
|
451
|
+
* 2. it resolves inside our own skills/ tree — then it IS our file
|
|
452
|
+
*
|
|
453
|
+
* The third signal (shipped name + output-contract marker) is NOT proof: the
|
|
454
|
+
* marker is a documentation path every agent is told to follow, and 14 of the
|
|
455
|
+
* shipped names are bare generic words. A user skill called "report" that cites
|
|
456
|
+
* the output contract matches it. That entry gets quarantined, never deleted.
|
|
457
|
+
*/
|
|
458
|
+
function isProvablyOurRdcSkill(candidate, skillFile) {
|
|
459
|
+
const fm = readFrontmatter(skillFile);
|
|
460
|
+
if (fm.name && fm.name.startsWith('rdc:')) return true;
|
|
461
|
+
return resolvesInsideOurSkills(candidate);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Where quarantined entries go. Printed, never silently removed. */
|
|
465
|
+
function rdcQuarantineDir(userSkillsDir) {
|
|
466
|
+
return path.join(userSkillsDir, '.rdc-quarantine');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Remove a duplicate — or, when we cannot prove it is ours, move it aside and
|
|
471
|
+
* say so. Returns 1 if the entry was dealt with, 0 if it was left alone.
|
|
472
|
+
*
|
|
473
|
+
* The log line is not decoration. The previous version deleted inside
|
|
474
|
+
* try{}catch{} and reported only a count, so a wrongly-removed user skill left
|
|
475
|
+
* no trace at all — indistinguishable from having removed nothing.
|
|
476
|
+
*/
|
|
477
|
+
function disposeRdcDuplicate(candidate, skillFile, { isDir, log = console.log } = {}) {
|
|
478
|
+
if (!isRdcSkillDuplicate(candidate, skillFile)) return 0;
|
|
479
|
+
|
|
480
|
+
if (isProvablyOurRdcSkill(candidate, skillFile)) {
|
|
481
|
+
try {
|
|
482
|
+
if (isDir) fs.rmSync(candidate, { recursive: true, force: true });
|
|
483
|
+
else fs.unlinkSync(candidate);
|
|
484
|
+
return 1;
|
|
485
|
+
} catch { return 0; }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Suspect only. Quarantine, and name the path so it can be put back.
|
|
489
|
+
try {
|
|
490
|
+
const parent = path.dirname(candidate);
|
|
491
|
+
const qdir = rdcQuarantineDir(parent);
|
|
492
|
+
fs.mkdirSync(qdir, { recursive: true });
|
|
493
|
+
const dest = path.join(qdir, path.basename(candidate));
|
|
494
|
+
if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
|
|
495
|
+
fs.renameSync(candidate, dest);
|
|
496
|
+
log(` quarantined (name matches a shipped skill, but provenance unproven): ${candidate} -> ${dest}`);
|
|
497
|
+
return 1;
|
|
498
|
+
} catch {
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function isRdcSkillDuplicate(candidate, skillFile) {
|
|
504
|
+
const fm = readFrontmatter(skillFile);
|
|
505
|
+
|
|
506
|
+
// 1. legacy rdc: prefix — pre-rename installs
|
|
507
|
+
if (fm.name && fm.name.startsWith('rdc:')) return true;
|
|
508
|
+
|
|
509
|
+
// 2. it literally is our file, reached by another path
|
|
510
|
+
if (resolvesInsideOurSkills(candidate)) return true;
|
|
511
|
+
|
|
512
|
+
// 3. our name AND our marker — both, never name alone
|
|
513
|
+
if (fm.name && shippedSkillNames().has(fm.name)) {
|
|
514
|
+
try {
|
|
515
|
+
if (fs.readFileSync(skillFile, 'utf8').includes(RDC_SKILL_MARKER)) return true;
|
|
516
|
+
} catch { /* unreadable → not a duplicate we are confident about */ }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
|
|
372
522
|
// ── User-skills cleanup ───────────────────────────────────────────────────────
|
|
373
523
|
// Older installer versions wrote skill files directly to ~/.claude/skills/user/.
|
|
374
524
|
// Claude Code loads that directory AND the plugin cache, so any rdc skills left
|
|
375
525
|
// there produce duplicate registrations and break the resolver.
|
|
376
|
-
//
|
|
526
|
+
// It delegates to isRdcSkillDuplicate() above — a prefix test alone has not
|
|
527
|
+
// matched anything since skills were renamed to bare names.
|
|
377
528
|
// Scans BOTH the immediate dir and nested .md files (e.g. `user/skill.md`,
|
|
378
529
|
// `user/rdc-build/SKILL.md`) so pre-plugin orphans are caught regardless of
|
|
379
530
|
// naming convention.
|
|
@@ -390,19 +541,13 @@ function cleanUserSkills(userSkillsDir) {
|
|
|
390
541
|
if (fs.existsSync(p)) { skillFile = p; break; }
|
|
391
542
|
}
|
|
392
543
|
if (!skillFile) continue;
|
|
393
|
-
|
|
394
|
-
if (fm.name && fm.name.startsWith('rdc:')) {
|
|
395
|
-
try { fs.rmSync(candidate, { recursive: true, force: true }); removed++; } catch {}
|
|
396
|
-
}
|
|
544
|
+
removed += disposeRdcDuplicate(candidate, skillFile, { isDir: true });
|
|
397
545
|
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
398
546
|
// ANY .md file at this level — including skill.md / SKILL.md / README.md
|
|
399
547
|
// if their frontmatter declares an rdc:* skill. A previous version skipped
|
|
400
548
|
// those names; that left an orphan rdc:build copy at user/skill.md which
|
|
401
549
|
// registered as a duplicate "user" skill.
|
|
402
|
-
|
|
403
|
-
if (fm.name && fm.name.startsWith('rdc:')) {
|
|
404
|
-
try { fs.unlinkSync(candidate); removed++; } catch {}
|
|
405
|
-
}
|
|
550
|
+
removed += disposeRdcDuplicate(candidate, candidate, { isDir: false });
|
|
406
551
|
}
|
|
407
552
|
}
|
|
408
553
|
return removed;
|
|
@@ -417,10 +562,7 @@ function cleanGlobalSkillsRoot(skillsDir) {
|
|
|
417
562
|
if (entry.name === 'user') continue; // handled separately
|
|
418
563
|
const candidate = path.join(skillsDir, entry.name);
|
|
419
564
|
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
420
|
-
|
|
421
|
-
if (fm.name && fm.name.startsWith('rdc:')) {
|
|
422
|
-
try { fs.unlinkSync(candidate); removed++; } catch {}
|
|
423
|
-
}
|
|
565
|
+
removed += disposeRdcDuplicate(candidate, candidate, { isDir: false });
|
|
424
566
|
}
|
|
425
567
|
}
|
|
426
568
|
return removed;
|
|
@@ -797,11 +939,7 @@ function registerCodexTarget(targetDir) {
|
|
|
797
939
|
fs.rmSync(candidate, { recursive: true, force: true });
|
|
798
940
|
removed++;
|
|
799
941
|
} else {
|
|
800
|
-
|
|
801
|
-
if (fm.name && fm.name.startsWith('rdc:')) {
|
|
802
|
-
fs.rmSync(candidate, { recursive: true, force: true });
|
|
803
|
-
removed++;
|
|
804
|
-
}
|
|
942
|
+
removed += disposeRdcDuplicate(candidate, path.join(candidate, 'SKILL.md'), { isDir: true });
|
|
805
943
|
}
|
|
806
944
|
}
|
|
807
945
|
|
|
@@ -1331,8 +1469,11 @@ async function main() {
|
|
|
1331
1469
|
? ['SKILL.md','skill.md'].map(s => path.join(p, s)).find(fs.existsSync)
|
|
1332
1470
|
: (f.endsWith('.md') ? p : null);
|
|
1333
1471
|
if (!skillFile) return false;
|
|
1334
|
-
|
|
1335
|
-
|
|
1472
|
+
// Must use the SAME predicate the purge uses. When this asked a
|
|
1473
|
+
// different (and dead) question, the purge removed nothing and this
|
|
1474
|
+
// confirmed nothing was left — the two agreed, and agreement between
|
|
1475
|
+
// a broken fix and a broken check is indistinguishable from success.
|
|
1476
|
+
return isRdcSkillDuplicate(p, skillFile);
|
|
1336
1477
|
})
|
|
1337
1478
|
: [];
|
|
1338
1479
|
if (stillThere.length === 0) {
|
|
@@ -1385,7 +1526,7 @@ async function main() {
|
|
|
1385
1526
|
// scripts/probe-installed-hooks.mjs verify the real install path instead of
|
|
1386
1527
|
// re-implementing it — a probe that copies its own way proves nothing about what
|
|
1387
1528
|
// ships. Without this guard, requiring the module would run a full install.
|
|
1388
|
-
module.exports = { copyHookFiles, assertHooksLoadable, registerCodexTarget, syncMarketplaceCheckout, RDC_ENV_HOOK_TIMEOUT_SEC };
|
|
1529
|
+
module.exports = { copyHookFiles, assertHooksLoadable, registerCodexTarget, syncMarketplaceCheckout, RDC_ENV_HOOK_TIMEOUT_SEC, isRdcSkillDuplicate, cleanUserSkills, cleanGlobalSkillsRoot, shippedSkillNames, isProvablyOurRdcSkill, disposeRdcDuplicate, rdcQuarantineDir };
|
|
1389
1530
|
|
|
1390
1531
|
if (require.main === module) {
|
|
1391
1532
|
main().catch(e => { fail(e.message); process.exit(1); });
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The duplicate-skill purge — the predicate, not the plumbing.
|
|
4
|
+
*
|
|
5
|
+
* cleanUserSkills() and cleanGlobalSkillsRoot() removed an entry only when its
|
|
6
|
+
* frontmatter name startsWith('rdc:'). Skills were later renamed to bare names
|
|
7
|
+
* (`name: build`), and measured 2026-08-30 exactly 0 of 44 shipped skills carry
|
|
8
|
+
* an rdc: prefix. So the purge matched nothing and removed nothing, while
|
|
9
|
+
* reporting 0 — indistinguishable from "there was nothing to remove".
|
|
10
|
+
*
|
|
11
|
+
* The first test below is the one that was failing in production and could not
|
|
12
|
+
* fail here, because nothing asserted the bare-name case. The rest exist so the
|
|
13
|
+
* fix cannot over-reach: a user's own skill must survive even when it shares a
|
|
14
|
+
* name with one we ship.
|
|
15
|
+
*/
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const REPO_ROOT = resolve(__dirname, '..');
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const {
|
|
27
|
+
isRdcSkillDuplicate, cleanUserSkills, cleanGlobalSkillsRoot, shippedSkillNames,
|
|
28
|
+
isProvablyOurRdcSkill, rdcQuarantineDir,
|
|
29
|
+
} = require(join(REPO_ROOT, 'scripts', 'install-rdc-skills.js'));
|
|
30
|
+
|
|
31
|
+
const MARKER = 'guides/output-contract.md';
|
|
32
|
+
let sandbox;
|
|
33
|
+
function fresh() {
|
|
34
|
+
sandbox = mkdtempSync(join(tmpdir(), 'rdc-purge-'));
|
|
35
|
+
return sandbox;
|
|
36
|
+
}
|
|
37
|
+
function skillDir(root, name, body) {
|
|
38
|
+
const d = join(root, name);
|
|
39
|
+
mkdirSync(d, { recursive: true });
|
|
40
|
+
writeFileSync(join(d, 'SKILL.md'), body);
|
|
41
|
+
return d;
|
|
42
|
+
}
|
|
43
|
+
function fm(name, extra = '') {
|
|
44
|
+
return `---\nname: ${name}\ndescription: test fixture\n---\n\n${extra}\n`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── 0. the shipped set is real, not empty ────────────────────────────────────
|
|
48
|
+
// If this is empty every other assertion below passes vacuously.
|
|
49
|
+
const shipped = shippedSkillNames();
|
|
50
|
+
assert.ok(shipped.size > 10, `shippedSkillNames() returned ${shipped.size} — too few to be real`);
|
|
51
|
+
assert.ok(shipped.has('build'), 'expected "build" among shipped skill names');
|
|
52
|
+
|
|
53
|
+
// ── 1. THE REGRESSION: a bare-named copy of a skill we ship is a duplicate ───
|
|
54
|
+
// This is precisely what the old startsWith('rdc:') predicate could not see.
|
|
55
|
+
{
|
|
56
|
+
const root = fresh();
|
|
57
|
+
const d = skillDir(root, 'build', fm('build', `> OUTPUT CONTRACT: \`${MARKER}\``));
|
|
58
|
+
assert.equal(isRdcSkillDuplicate(d, join(d, 'SKILL.md')), true,
|
|
59
|
+
'a bare-named copy of a shipped skill must be detected as a duplicate');
|
|
60
|
+
const removed = cleanUserSkills(root);
|
|
61
|
+
assert.equal(removed, 1, 'cleanUserSkills should remove exactly the duplicate');
|
|
62
|
+
assert.equal(existsSync(d), false, 'the duplicate directory should be gone');
|
|
63
|
+
rmSync(root, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── 2. legacy rdc:-prefixed entries still get cleaned ────────────────────────
|
|
67
|
+
// Pre-rename installs are the machines this function was originally written for.
|
|
68
|
+
{
|
|
69
|
+
const root = fresh();
|
|
70
|
+
const d = skillDir(root, 'rdc-build', fm('rdc:build'));
|
|
71
|
+
assert.equal(isRdcSkillDuplicate(d, join(d, 'SKILL.md')), true, 'legacy rdc: prefix must still match');
|
|
72
|
+
assert.equal(cleanUserSkills(root), 1);
|
|
73
|
+
assert.equal(existsSync(d), false);
|
|
74
|
+
rmSync(root, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 3. SAFETY: a user's own skill with an unrelated name survives ────────────
|
|
78
|
+
{
|
|
79
|
+
const root = fresh();
|
|
80
|
+
const d = skillDir(root, 'leaflet-maps', fm('leaflet-maps'));
|
|
81
|
+
assert.equal(isRdcSkillDuplicate(d, join(d, 'SKILL.md')), false);
|
|
82
|
+
assert.equal(cleanUserSkills(root), 0, 'must not touch a user skill');
|
|
83
|
+
assert.equal(existsSync(d), true, "the user's own skill must survive");
|
|
84
|
+
rmSync(root, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── 4. SAFETY: a shipped NAME alone is not enough to delete ──────────────────
|
|
88
|
+
// Someone may legitimately write their own skill called "build". Name plus our
|
|
89
|
+
// marker is required; name alone must never be sufficient.
|
|
90
|
+
{
|
|
91
|
+
const root = fresh();
|
|
92
|
+
const d = skillDir(root, 'build', fm('build', 'my own build skill, nothing to do with rdc'));
|
|
93
|
+
assert.equal(isRdcSkillDuplicate(d, join(d, 'SKILL.md')), false,
|
|
94
|
+
'shipped name WITHOUT our marker must not be treated as ours');
|
|
95
|
+
assert.equal(cleanUserSkills(root), 0);
|
|
96
|
+
assert.equal(existsSync(d), true);
|
|
97
|
+
rmSync(root, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── 5. a symlink into our own skills/ tree is unambiguously ours ─────────────
|
|
101
|
+
// Skipped where the platform refuses symlink creation without privileges; the
|
|
102
|
+
// skip is loud, so it can never be mistaken for a pass.
|
|
103
|
+
{
|
|
104
|
+
const root = fresh();
|
|
105
|
+
const target = join(REPO_ROOT, 'skills', 'build');
|
|
106
|
+
let made = true;
|
|
107
|
+
try { symlinkSync(target, join(root, 'build'), 'junction'); }
|
|
108
|
+
catch { made = false; }
|
|
109
|
+
if (made) {
|
|
110
|
+
assert.equal(isRdcSkillDuplicate(join(root, 'build'), join(root, 'build', 'SKILL.md')), true,
|
|
111
|
+
'a symlink resolving inside our skills/ tree must be detected');
|
|
112
|
+
} else {
|
|
113
|
+
console.log(' SKIP symlink case — platform refused symlink creation');
|
|
114
|
+
}
|
|
115
|
+
rmSync(root, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── 6. cleanGlobalSkillsRoot: flat .md orphans at the top level ─────────────
|
|
119
|
+
{
|
|
120
|
+
const root = fresh();
|
|
121
|
+
const flat = join(root, 'build.md');
|
|
122
|
+
writeFileSync(flat, fm('build', `> OUTPUT CONTRACT: \`${MARKER}\``));
|
|
123
|
+
const mine = join(root, 'coolify-verify.md');
|
|
124
|
+
writeFileSync(mine, fm('coolify-verify'));
|
|
125
|
+
const removed = cleanGlobalSkillsRoot(root);
|
|
126
|
+
assert.equal(removed, 1, 'exactly the rdc orphan should go');
|
|
127
|
+
assert.equal(existsSync(flat), false, 'flat rdc orphan should be removed');
|
|
128
|
+
assert.equal(existsSync(mine), true, "the user's own flat skill must survive");
|
|
129
|
+
rmSync(root, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── 7. absent directory is not an error ──────────────────────────────────────
|
|
133
|
+
assert.equal(cleanUserSkills(join(tmpdir(), 'rdc-purge-does-not-exist')), 0);
|
|
134
|
+
assert.equal(cleanGlobalSkillsRoot(join(tmpdir(), 'rdc-purge-does-not-exist')), 0);
|
|
135
|
+
|
|
136
|
+
// ── 8. C2 REGRESSION: a user's own skill is never irrecoverably deleted ──────
|
|
137
|
+
//
|
|
138
|
+
// Found by independent code review and proven against the real module. Signal 3
|
|
139
|
+
// is "shipped name AND body cites guides/output-contract.md" — but that marker
|
|
140
|
+
// is a DOCUMENTATION PATH that CLAUDE.md and AGENTS.md tell every agent to
|
|
141
|
+
// follow, and 14 of the shipped names are bare generic words (build, report,
|
|
142
|
+
// status, design, open, edit, plan, deploy, help, watch, convert, release,
|
|
143
|
+
// collab, review). A user skill called "report" that cites the output contract
|
|
144
|
+
// matched, and was rmSync'd recursively with no backup and no log.
|
|
145
|
+
//
|
|
146
|
+
// It must still be MOVED (it does shadow a shipped skill), but it must be
|
|
147
|
+
// recoverable, and the entry must not be classed as provably ours.
|
|
148
|
+
{
|
|
149
|
+
const root = fresh();
|
|
150
|
+
const d = skillDir(root, 'report',
|
|
151
|
+
fm('report', 'My own reporting skill. House style: follow .rdc/guides/output-contract.md.'));
|
|
152
|
+
|
|
153
|
+
assert.equal(isProvablyOurRdcSkill(d, join(d, 'SKILL.md')), false,
|
|
154
|
+
"a user's own skill citing the output contract must NOT be classed as provably ours");
|
|
155
|
+
|
|
156
|
+
const removed = cleanUserSkills(root);
|
|
157
|
+
assert.equal(removed, 1, 'it still shadows a shipped skill, so it is dealt with');
|
|
158
|
+
assert.equal(existsSync(d), false, 'moved out of the load path');
|
|
159
|
+
|
|
160
|
+
const quarantined = join(rdcQuarantineDir(root), 'report');
|
|
161
|
+
assert.equal(existsSync(quarantined), true,
|
|
162
|
+
'RECOVERABLE: it must be in quarantine, never deleted');
|
|
163
|
+
assert.equal(existsSync(join(quarantined, 'SKILL.md')), true, 'contents preserved intact');
|
|
164
|
+
rmSync(root, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── 9. provably-ours entries ARE deleted outright, not quarantined ───────────
|
|
168
|
+
// Quarantine is for uncertainty. A legacy rdc:-prefixed copy is unambiguous, and
|
|
169
|
+
// leaving it in a sibling directory would just be litter.
|
|
170
|
+
{
|
|
171
|
+
const root = fresh();
|
|
172
|
+
const d = skillDir(root, 'rdc-plan', fm('rdc:plan'));
|
|
173
|
+
assert.equal(isProvablyOurRdcSkill(d, join(d, 'SKILL.md')), true);
|
|
174
|
+
assert.equal(cleanUserSkills(root), 1);
|
|
175
|
+
assert.equal(existsSync(d), false);
|
|
176
|
+
assert.equal(existsSync(join(rdcQuarantineDir(root), 'rdc-plan')), false,
|
|
177
|
+
'provably ours is deleted, not quarantined');
|
|
178
|
+
rmSync(root, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log('install-rdc-skills duplicate-purge test — PASS');
|