@lifeaitools/rdc-skills 0.35.16 → 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.16",
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.16",
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",
@@ -417,7 +417,16 @@ function shippedSkillNames() {
417
417
  }
418
418
  }
419
419
  }
420
- } catch { /* no skills/ dir — every check below simply returns false */ }
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
+ }
421
430
  return _shippedSkillNames;
422
431
  }
423
432
 
@@ -434,6 +443,63 @@ function resolvesInsideOurSkills(candidate) {
434
443
  }
435
444
  }
436
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
+
437
503
  function isRdcSkillDuplicate(candidate, skillFile) {
438
504
  const fm = readFrontmatter(skillFile);
439
505
 
@@ -475,17 +541,13 @@ function cleanUserSkills(userSkillsDir) {
475
541
  if (fs.existsSync(p)) { skillFile = p; break; }
476
542
  }
477
543
  if (!skillFile) continue;
478
- if (isRdcSkillDuplicate(candidate, skillFile)) {
479
- try { fs.rmSync(candidate, { recursive: true, force: true }); removed++; } catch {}
480
- }
544
+ removed += disposeRdcDuplicate(candidate, skillFile, { isDir: true });
481
545
  } else if (entry.isFile() && entry.name.endsWith('.md')) {
482
546
  // ANY .md file at this level — including skill.md / SKILL.md / README.md
483
547
  // if their frontmatter declares an rdc:* skill. A previous version skipped
484
548
  // those names; that left an orphan rdc:build copy at user/skill.md which
485
549
  // registered as a duplicate "user" skill.
486
- if (isRdcSkillDuplicate(candidate, candidate)) {
487
- try { fs.unlinkSync(candidate); removed++; } catch {}
488
- }
550
+ removed += disposeRdcDuplicate(candidate, candidate, { isDir: false });
489
551
  }
490
552
  }
491
553
  return removed;
@@ -500,9 +562,7 @@ function cleanGlobalSkillsRoot(skillsDir) {
500
562
  if (entry.name === 'user') continue; // handled separately
501
563
  const candidate = path.join(skillsDir, entry.name);
502
564
  if (entry.isFile() && entry.name.endsWith('.md')) {
503
- if (isRdcSkillDuplicate(candidate, candidate)) {
504
- try { fs.unlinkSync(candidate); removed++; } catch {}
505
- }
565
+ removed += disposeRdcDuplicate(candidate, candidate, { isDir: false });
506
566
  }
507
567
  }
508
568
  return removed;
@@ -879,10 +939,7 @@ function registerCodexTarget(targetDir) {
879
939
  fs.rmSync(candidate, { recursive: true, force: true });
880
940
  removed++;
881
941
  } else {
882
- if (isRdcSkillDuplicate(candidate, path.join(candidate, 'SKILL.md'))) {
883
- fs.rmSync(candidate, { recursive: true, force: true });
884
- removed++;
885
- }
942
+ removed += disposeRdcDuplicate(candidate, path.join(candidate, 'SKILL.md'), { isDir: true });
886
943
  }
887
944
  }
888
945
 
@@ -1469,7 +1526,7 @@ async function main() {
1469
1526
  // scripts/probe-installed-hooks.mjs verify the real install path instead of
1470
1527
  // re-implementing it — a probe that copies its own way proves nothing about what
1471
1528
  // ships. Without this guard, requiring the module would run a full install.
1472
- module.exports = { copyHookFiles, assertHooksLoadable, registerCodexTarget, syncMarketplaceCheckout, RDC_ENV_HOOK_TIMEOUT_SEC, isRdcSkillDuplicate, cleanUserSkills, cleanGlobalSkillsRoot, shippedSkillNames };
1529
+ module.exports = { copyHookFiles, assertHooksLoadable, registerCodexTarget, syncMarketplaceCheckout, RDC_ENV_HOOK_TIMEOUT_SEC, isRdcSkillDuplicate, cleanUserSkills, cleanGlobalSkillsRoot, shippedSkillNames, isProvablyOurRdcSkill, disposeRdcDuplicate, rdcQuarantineDir };
1473
1530
 
1474
1531
  if (require.main === module) {
1475
1532
  main().catch(e => { fail(e.message); process.exit(1); });
@@ -23,8 +23,10 @@ import { fileURLToPath } from 'node:url';
23
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
24
  const REPO_ROOT = resolve(__dirname, '..');
25
25
  const require = createRequire(import.meta.url);
26
- const { isRdcSkillDuplicate, cleanUserSkills, cleanGlobalSkillsRoot, shippedSkillNames } =
27
- require(join(REPO_ROOT, 'scripts', 'install-rdc-skills.js'));
26
+ const {
27
+ isRdcSkillDuplicate, cleanUserSkills, cleanGlobalSkillsRoot, shippedSkillNames,
28
+ isProvablyOurRdcSkill, rdcQuarantineDir,
29
+ } = require(join(REPO_ROOT, 'scripts', 'install-rdc-skills.js'));
28
30
 
29
31
  const MARKER = 'guides/output-contract.md';
30
32
  let sandbox;
@@ -131,4 +133,49 @@ assert.ok(shipped.has('build'), 'expected "build" among shipped skill names');
131
133
  assert.equal(cleanUserSkills(join(tmpdir(), 'rdc-purge-does-not-exist')), 0);
132
134
  assert.equal(cleanGlobalSkillsRoot(join(tmpdir(), 'rdc-purge-does-not-exist')), 0);
133
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
+
134
181
  console.log('install-rdc-skills duplicate-purge test — PASS');