@lifeaitools/rdc-skills 0.35.16 → 0.35.18
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.18",
|
|
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
|
@@ -417,7 +417,16 @@ function shippedSkillNames() {
|
|
|
417
417
|
}
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
|
-
} catch { /* no skills/ dir —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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); });
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
import { dirname, join, resolve } from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { mkdtempSync } from 'node:fs';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
23
25
|
|
|
24
26
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
27
|
const REPO_ROOT = resolve(__dirname, '..');
|
|
@@ -31,10 +33,27 @@ function assert(name, condition, detail = '') {
|
|
|
31
33
|
else process.stdout.write(` ok ${name}\n`);
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Every spawn gets an ISOLATED block ledger.
|
|
38
|
+
*
|
|
39
|
+
* This suite deliberately drives blocking fixtures, and the hook now records
|
|
40
|
+
* each refusal through the guard mitigator. Without an override it writes the
|
|
41
|
+
* REAL session ledger under %TEMP%/lifeai-guard-blocks — measured 2026-08-30,
|
|
42
|
+
* which already held this suite's fixtures ("npx playwright test --headed" and
|
|
43
|
+
* friends). A few suite runs cross REPEAT_THRESHOLD, after which a genuine
|
|
44
|
+
* --headed block reports "RULE DEFECT SUSPECTED" against a rule that is working
|
|
45
|
+
* perfectly. Test runs must never be able to discredit a live guard.
|
|
46
|
+
*
|
|
47
|
+
* lifeai-env's own guard tests carry this isolation plus a regression assertion
|
|
48
|
+
* that the live ledger is untouched; these two hooks shipped without either.
|
|
49
|
+
*/
|
|
50
|
+
const LEDGER = mkdtempSync(join(tmpdir(), 'fpg-ledger-'));
|
|
51
|
+
|
|
34
52
|
function runGate(command) {
|
|
35
53
|
const res = spawnSync(process.execPath, [HOOK], {
|
|
36
54
|
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command } }),
|
|
37
55
|
encoding: 'utf8',
|
|
56
|
+
env: { ...process.env, LIFEAI_GUARD_BLOCK_DIR: LEDGER, RDC_TEST: '1' },
|
|
38
57
|
});
|
|
39
58
|
const blocked = res.status === 1;
|
|
40
59
|
return { blocked, stdout: res.stdout, status: res.status };
|
|
@@ -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 {
|
|
27
|
-
|
|
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');
|
|
@@ -66,11 +66,24 @@ function makeRepo() {
|
|
|
66
66
|
return { dir, head };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Isolated block ledger — see the same note in foreground-process-gate.test.mjs.
|
|
71
|
+
*
|
|
72
|
+
* This hook records refusals through the guard mitigator, which keys on
|
|
73
|
+
* (rule id, argv shape) and escalates once a shape repeats. Fixtures that
|
|
74
|
+
* deliberately trigger a refusal would otherwise accumulate in the REAL session
|
|
75
|
+
* ledger under %TEMP%/lifeai-guard-blocks and eventually make a genuine refusal
|
|
76
|
+
* report "RULE DEFECT SUSPECTED" against a rule that is working.
|
|
77
|
+
*
|
|
78
|
+
* extraEnv still wins, so a case can override either value deliberately.
|
|
79
|
+
*/
|
|
80
|
+
const LEDGER = mkdtempSync(join(tmpdir(), 'rwi-ledger-'));
|
|
81
|
+
|
|
69
82
|
function runHook(payload, extraEnv = {}) {
|
|
70
83
|
return spawnSync(process.execPath, [HOOK], {
|
|
71
84
|
input: JSON.stringify(payload),
|
|
72
85
|
encoding: 'utf8',
|
|
73
|
-
env: { ...process.env, ...extraEnv },
|
|
86
|
+
env: { ...process.env, LIFEAI_GUARD_BLOCK_DIR: LEDGER, RDC_TEST: '1', ...extraEnv },
|
|
74
87
|
});
|
|
75
88
|
}
|
|
76
89
|
|