@lifeaitools/rdc-skills 0.35.14 → 0.35.16
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.16",
|
|
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",
|
|
@@ -25,6 +25,33 @@
|
|
|
25
25
|
*/
|
|
26
26
|
const hookLog = require('./hook-logger');
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The mitigator — OPTIONAL, because this hook crosses a repo boundary.
|
|
30
|
+
*
|
|
31
|
+
* EVERY BLOCK ROUTES THROUGH THE MITIGATOR (operator: "any block"). Audited
|
|
32
|
+
* 2026-08-30: 9 PreToolUse hooks wired, only the two guard dispatchers called
|
|
33
|
+
* mitigate(), so seven families refused with no repeat count and no
|
|
34
|
+
* classification — the signal that caught rtp-steering-grep being wrong in
|
|
35
|
+
* BOTH directions.
|
|
36
|
+
*
|
|
37
|
+
* This file ships in rdc-skills and installs to ~/.claude/hooks. The mitigator
|
|
38
|
+
* lives in lifeai-env, which is a SEPARATE repo that a consumer of this package
|
|
39
|
+
* may not have. So the import is best-effort and its absence is not an error:
|
|
40
|
+
* the block still lands, it simply arrives unannotated. A refusal that depended
|
|
41
|
+
* on a sibling repo being installed would be worse than no annotation at all.
|
|
42
|
+
*/
|
|
43
|
+
let mitigator = null;
|
|
44
|
+
async function loadMitigator() {
|
|
45
|
+
try {
|
|
46
|
+
const path = require('node:path');
|
|
47
|
+
const fs = require('node:fs');
|
|
48
|
+
const envRoot = process.env.LIFEAI_ENV || 'C:/Dev/lifeai-env';
|
|
49
|
+
const file = path.join(envRoot, 'hooks', 'lib', 'guard-mitigator.mjs');
|
|
50
|
+
if (!fs.existsSync(file)) return; // no lifeai-env here — that is fine
|
|
51
|
+
mitigator = await import(require('node:url').pathToFileURL(file).href);
|
|
52
|
+
} catch { /* annotation is never load-bearing */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
function readStdin() {
|
|
29
56
|
return new Promise((resolve) => {
|
|
30
57
|
let input = '';
|
|
@@ -37,8 +64,28 @@ function readStdin() {
|
|
|
37
64
|
|
|
38
65
|
function block(message, details = {}) {
|
|
39
66
|
hookLog('foreground-process-gate', 'PreToolUse', 'block', details);
|
|
67
|
+
|
|
68
|
+
let mitigation = '';
|
|
69
|
+
try {
|
|
70
|
+
if (mitigator) {
|
|
71
|
+
// details.kind already distinguishes the two refusals, so the rule id is
|
|
72
|
+
// taken from it rather than parsed out of the message — guard-mitigator
|
|
73
|
+
// keys on (rule id, argv shape) and explicitly forbids reading wording.
|
|
74
|
+
mitigation = mitigator.mitigationLine(mitigator.mitigate(
|
|
75
|
+
{
|
|
76
|
+
rule: `foreground-process-gate-${details.kind || 'unknown'}`,
|
|
77
|
+
cmd: blockSubject,
|
|
78
|
+
cwd: process.cwd(),
|
|
79
|
+
dir: mitigator.blockLedgerDir(),
|
|
80
|
+
sessionId: process.env.LIFEAI_SESSION_ID,
|
|
81
|
+
},
|
|
82
|
+
{ evaluate: null }, // a headed-browser refusal has no command to re-evaluate
|
|
83
|
+
));
|
|
84
|
+
}
|
|
85
|
+
} catch { /* annotation is never load-bearing */ }
|
|
86
|
+
|
|
40
87
|
process.stdout.write(JSON.stringify({
|
|
41
|
-
systemMessage: `HARD BLOCK — Foreground process launch rejected.\n\n${message}`,
|
|
88
|
+
systemMessage: `HARD BLOCK — Foreground process launch rejected.\n\n${message}${mitigation ? `\n\n${mitigation}` : ''}`,
|
|
42
89
|
}));
|
|
43
90
|
process.exit(1);
|
|
44
91
|
}
|
|
@@ -72,12 +119,20 @@ function checkPlaywright(command) {
|
|
|
72
119
|
}
|
|
73
120
|
}
|
|
74
121
|
|
|
122
|
+
let blockSubject = '';
|
|
123
|
+
|
|
75
124
|
async function main() {
|
|
76
125
|
let raw;
|
|
77
126
|
try { raw = JSON.parse(await readStdin()); } catch { process.exit(0); }
|
|
78
127
|
const command = toolText(raw);
|
|
79
128
|
if (!command) pass({ reason: 'no-command' });
|
|
80
129
|
|
|
130
|
+
// Preload BEFORE any check can block, so block() stays synchronous. The
|
|
131
|
+
// subject is the command itself, so repeated attempts at the SAME shape
|
|
132
|
+
// register as repeats rather than as unrelated events.
|
|
133
|
+
await loadMitigator();
|
|
134
|
+
blockSubject = command;
|
|
135
|
+
|
|
81
136
|
checkPlaywright(command);
|
|
82
137
|
|
|
83
138
|
pass({ reason: 'clean' });
|
|
@@ -53,6 +53,54 @@ const os = require('os');
|
|
|
53
53
|
const { execFileSync } = require('child_process');
|
|
54
54
|
const hookLog = require('./hook-logger');
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The mitigator — OPTIONAL, because this hook crosses a repo boundary.
|
|
58
|
+
*
|
|
59
|
+
* EVERY BLOCK ROUTES THROUGH THE MITIGATOR (operator: "any block"). Audited
|
|
60
|
+
* 2026-08-30: 9 PreToolUse hooks wired, only the two guard dispatchers called
|
|
61
|
+
* mitigate(), so seven families refused with no repeat count and no
|
|
62
|
+
* classification.
|
|
63
|
+
*
|
|
64
|
+
* This file is the reason that matters most. Its unreadable-message refusal
|
|
65
|
+
* fired TWICE in one session on `git commit -F "$SP/msg.txt"` — a path the
|
|
66
|
+
* check cannot resolve because it holds a shell variable, not because anything
|
|
67
|
+
* was wrong with the message. Two identical refusals, no repeat count, nothing
|
|
68
|
+
* to distinguish "you keep doing this" from "this rule keeps being wrong". That
|
|
69
|
+
* is exactly the signal the mitigator exists to raise, and this hook could not
|
|
70
|
+
* raise it.
|
|
71
|
+
*
|
|
72
|
+
* Ships in rdc-skills and installs to ~/.claude/hooks, where lifeai-env may not
|
|
73
|
+
* exist, so the import is best-effort and its absence is not an error.
|
|
74
|
+
*/
|
|
75
|
+
let mitigator = null;
|
|
76
|
+
let blockSubject = '';
|
|
77
|
+
async function loadMitigator() {
|
|
78
|
+
try {
|
|
79
|
+
const envRoot = process.env.LIFEAI_ENV || 'C:/Dev/lifeai-env';
|
|
80
|
+
const file = path.join(envRoot, 'hooks', 'lib', 'guard-mitigator.mjs');
|
|
81
|
+
if (!fs.existsSync(file)) return; // no lifeai-env here — that is fine
|
|
82
|
+
mitigator = await import(require('node:url').pathToFileURL(file).href);
|
|
83
|
+
} catch { /* annotation is never load-bearing */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One emit path for both refusals, so an annotation cannot be added to one and forgotten on the other. */
|
|
87
|
+
function denyWith(rule, reason) {
|
|
88
|
+
let mitigation = '';
|
|
89
|
+
try {
|
|
90
|
+
if (mitigator) {
|
|
91
|
+
mitigation = mitigator.mitigationLine(mitigator.mitigate(
|
|
92
|
+
{ rule, cmd: blockSubject, cwd: process.cwd(), dir: mitigator.blockLedgerDir(), sessionId: process.env.LIFEAI_SESSION_ID },
|
|
93
|
+
{ evaluate: null }, // a commit-message refusal has no command to re-evaluate
|
|
94
|
+
));
|
|
95
|
+
}
|
|
96
|
+
} catch { /* annotation is never load-bearing */ }
|
|
97
|
+
process.stdout.write(JSON.stringify({
|
|
98
|
+
decision: 'block',
|
|
99
|
+
reason: mitigation ? `${reason}\n\n${mitigation}` : reason,
|
|
100
|
+
}));
|
|
101
|
+
return process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
|
|
56
104
|
const MARKER_FILE = path.join(
|
|
57
105
|
process.env.USERPROFILE || process.env.HOME || os.homedir(),
|
|
58
106
|
'.claude',
|
|
@@ -330,14 +378,13 @@ async function preToolUse(raw) {
|
|
|
330
378
|
// for the wrong thing.
|
|
331
379
|
if (!readable) {
|
|
332
380
|
hookLog('require-work-item', 'PreToolUse', 'block-unreadable', {});
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
381
|
+
return denyWith(
|
|
382
|
+
'require-work-item-unreadable',
|
|
383
|
+
'⛔ [require-work-item] The commit message arrives on stdin, so this check cannot read it '
|
|
336
384
|
+ 'and will not guess. Nothing is wrong with your message — it is unverifiable from here. '
|
|
337
385
|
+ 'Re-run with the message where the check can see it: `git commit -F <path>` (a file), '
|
|
338
386
|
+ 'a heredoc (`git commit -F - <<\'EOF\' … EOF`), or `-m "<type>(<scope>): …"`.',
|
|
339
|
-
|
|
340
|
-
return process.exit(0);
|
|
387
|
+
);
|
|
341
388
|
}
|
|
342
389
|
|
|
343
390
|
if (CONVENTIONAL_TYPES.test(msg.trim()) || UUID_PATTERN.test(msg) || ISSUE_REF.test(msg)) {
|
|
@@ -349,14 +396,13 @@ async function preToolUse(raw) {
|
|
|
349
396
|
// or none declared at all) requires a work item and none was referenced —
|
|
350
397
|
// this used to print a sentence and let the commit through regardless.
|
|
351
398
|
hookLog('require-work-item', 'PreToolUse', 'block', { msg: msg.slice(0, 80) });
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
399
|
+
return denyWith(
|
|
400
|
+
'require-work-item',
|
|
401
|
+
'⛔ [require-work-item] No work item reference or conventional commit type, and the current '
|
|
355
402
|
+ 'flow requires one (build/refactor/overnight, or no flow declared). Add a Work-Item UUID or '
|
|
356
403
|
+ '#issue ref to the message, use fix(<scope>): ..., or declare a flow that does not need one '
|
|
357
404
|
+ '(rdc-flow.mjs: plan/design/collab) if this genuinely ships no trackable work.',
|
|
358
|
-
|
|
359
|
-
return process.exit(0);
|
|
405
|
+
);
|
|
360
406
|
}
|
|
361
407
|
|
|
362
408
|
// ---------------------------------------------------------------------------
|
|
@@ -375,6 +421,12 @@ async function main() {
|
|
|
375
421
|
let raw;
|
|
376
422
|
try { raw = JSON.parse(input); } catch { return process.exit(0); }
|
|
377
423
|
|
|
424
|
+
// Preload BEFORE any refusal can fire, so denyWith() stays synchronous. The
|
|
425
|
+
// subject is the command, so repeated refusals of the SAME shape register as
|
|
426
|
+
// repeats — which is the whole point for this hook in particular.
|
|
427
|
+
await loadMitigator();
|
|
428
|
+
blockSubject = String(raw?.tool_input?.command || '');
|
|
429
|
+
|
|
378
430
|
const event = raw.hook_event_name || raw.hookEventName || 'PreToolUse';
|
|
379
431
|
|
|
380
432
|
if (event === 'PostToolUse') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lifeaitools/rdc-skills",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.16",
|
|
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,96 @@ 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 — every check below simply returns false */ }
|
|
421
|
+
return _shippedSkillNames;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const RDC_SKILL_MARKER = 'guides/output-contract.md';
|
|
425
|
+
|
|
426
|
+
function resolvesInsideOurSkills(candidate) {
|
|
427
|
+
try {
|
|
428
|
+
const real = fs.realpathSync(candidate);
|
|
429
|
+
const ours = fs.realpathSync(path.join(repoRoot, 'skills'));
|
|
430
|
+
const rel = path.relative(ours, real);
|
|
431
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
432
|
+
} catch {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isRdcSkillDuplicate(candidate, skillFile) {
|
|
438
|
+
const fm = readFrontmatter(skillFile);
|
|
439
|
+
|
|
440
|
+
// 1. legacy rdc: prefix — pre-rename installs
|
|
441
|
+
if (fm.name && fm.name.startsWith('rdc:')) return true;
|
|
442
|
+
|
|
443
|
+
// 2. it literally is our file, reached by another path
|
|
444
|
+
if (resolvesInsideOurSkills(candidate)) return true;
|
|
445
|
+
|
|
446
|
+
// 3. our name AND our marker — both, never name alone
|
|
447
|
+
if (fm.name && shippedSkillNames().has(fm.name)) {
|
|
448
|
+
try {
|
|
449
|
+
if (fs.readFileSync(skillFile, 'utf8').includes(RDC_SKILL_MARKER)) return true;
|
|
450
|
+
} catch { /* unreadable → not a duplicate we are confident about */ }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
|
|
372
456
|
// ── User-skills cleanup ───────────────────────────────────────────────────────
|
|
373
457
|
// Older installer versions wrote skill files directly to ~/.claude/skills/user/.
|
|
374
458
|
// Claude Code loads that directory AND the plugin cache, so any rdc skills left
|
|
375
459
|
// there produce duplicate registrations and break the resolver.
|
|
376
|
-
//
|
|
460
|
+
// It delegates to isRdcSkillDuplicate() above — a prefix test alone has not
|
|
461
|
+
// matched anything since skills were renamed to bare names.
|
|
377
462
|
// Scans BOTH the immediate dir and nested .md files (e.g. `user/skill.md`,
|
|
378
463
|
// `user/rdc-build/SKILL.md`) so pre-plugin orphans are caught regardless of
|
|
379
464
|
// naming convention.
|
|
@@ -390,8 +475,7 @@ function cleanUserSkills(userSkillsDir) {
|
|
|
390
475
|
if (fs.existsSync(p)) { skillFile = p; break; }
|
|
391
476
|
}
|
|
392
477
|
if (!skillFile) continue;
|
|
393
|
-
|
|
394
|
-
if (fm.name && fm.name.startsWith('rdc:')) {
|
|
478
|
+
if (isRdcSkillDuplicate(candidate, skillFile)) {
|
|
395
479
|
try { fs.rmSync(candidate, { recursive: true, force: true }); removed++; } catch {}
|
|
396
480
|
}
|
|
397
481
|
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
@@ -399,8 +483,7 @@ function cleanUserSkills(userSkillsDir) {
|
|
|
399
483
|
// if their frontmatter declares an rdc:* skill. A previous version skipped
|
|
400
484
|
// those names; that left an orphan rdc:build copy at user/skill.md which
|
|
401
485
|
// registered as a duplicate "user" skill.
|
|
402
|
-
|
|
403
|
-
if (fm.name && fm.name.startsWith('rdc:')) {
|
|
486
|
+
if (isRdcSkillDuplicate(candidate, candidate)) {
|
|
404
487
|
try { fs.unlinkSync(candidate); removed++; } catch {}
|
|
405
488
|
}
|
|
406
489
|
}
|
|
@@ -417,8 +500,7 @@ function cleanGlobalSkillsRoot(skillsDir) {
|
|
|
417
500
|
if (entry.name === 'user') continue; // handled separately
|
|
418
501
|
const candidate = path.join(skillsDir, entry.name);
|
|
419
502
|
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
420
|
-
|
|
421
|
-
if (fm.name && fm.name.startsWith('rdc:')) {
|
|
503
|
+
if (isRdcSkillDuplicate(candidate, candidate)) {
|
|
422
504
|
try { fs.unlinkSync(candidate); removed++; } catch {}
|
|
423
505
|
}
|
|
424
506
|
}
|
|
@@ -797,8 +879,7 @@ function registerCodexTarget(targetDir) {
|
|
|
797
879
|
fs.rmSync(candidate, { recursive: true, force: true });
|
|
798
880
|
removed++;
|
|
799
881
|
} else {
|
|
800
|
-
|
|
801
|
-
if (fm.name && fm.name.startsWith('rdc:')) {
|
|
882
|
+
if (isRdcSkillDuplicate(candidate, path.join(candidate, 'SKILL.md'))) {
|
|
802
883
|
fs.rmSync(candidate, { recursive: true, force: true });
|
|
803
884
|
removed++;
|
|
804
885
|
}
|
|
@@ -1331,8 +1412,11 @@ async function main() {
|
|
|
1331
1412
|
? ['SKILL.md','skill.md'].map(s => path.join(p, s)).find(fs.existsSync)
|
|
1332
1413
|
: (f.endsWith('.md') ? p : null);
|
|
1333
1414
|
if (!skillFile) return false;
|
|
1334
|
-
|
|
1335
|
-
|
|
1415
|
+
// Must use the SAME predicate the purge uses. When this asked a
|
|
1416
|
+
// different (and dead) question, the purge removed nothing and this
|
|
1417
|
+
// confirmed nothing was left — the two agreed, and agreement between
|
|
1418
|
+
// a broken fix and a broken check is indistinguishable from success.
|
|
1419
|
+
return isRdcSkillDuplicate(p, skillFile);
|
|
1336
1420
|
})
|
|
1337
1421
|
: [];
|
|
1338
1422
|
if (stillThere.length === 0) {
|
|
@@ -1385,7 +1469,7 @@ async function main() {
|
|
|
1385
1469
|
// scripts/probe-installed-hooks.mjs verify the real install path instead of
|
|
1386
1470
|
// re-implementing it — a probe that copies its own way proves nothing about what
|
|
1387
1471
|
// ships. Without this guard, requiring the module would run a full install.
|
|
1388
|
-
module.exports = { copyHookFiles, assertHooksLoadable, registerCodexTarget, syncMarketplaceCheckout, RDC_ENV_HOOK_TIMEOUT_SEC };
|
|
1472
|
+
module.exports = { copyHookFiles, assertHooksLoadable, registerCodexTarget, syncMarketplaceCheckout, RDC_ENV_HOOK_TIMEOUT_SEC, isRdcSkillDuplicate, cleanUserSkills, cleanGlobalSkillsRoot, shippedSkillNames };
|
|
1389
1473
|
|
|
1390
1474
|
if (require.main === module) {
|
|
1391
1475
|
main().catch(e => { fail(e.message); process.exit(1); });
|
|
@@ -0,0 +1,134 @@
|
|
|
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 { isRdcSkillDuplicate, cleanUserSkills, cleanGlobalSkillsRoot, shippedSkillNames } =
|
|
27
|
+
require(join(REPO_ROOT, 'scripts', 'install-rdc-skills.js'));
|
|
28
|
+
|
|
29
|
+
const MARKER = 'guides/output-contract.md';
|
|
30
|
+
let sandbox;
|
|
31
|
+
function fresh() {
|
|
32
|
+
sandbox = mkdtempSync(join(tmpdir(), 'rdc-purge-'));
|
|
33
|
+
return sandbox;
|
|
34
|
+
}
|
|
35
|
+
function skillDir(root, name, body) {
|
|
36
|
+
const d = join(root, name);
|
|
37
|
+
mkdirSync(d, { recursive: true });
|
|
38
|
+
writeFileSync(join(d, 'SKILL.md'), body);
|
|
39
|
+
return d;
|
|
40
|
+
}
|
|
41
|
+
function fm(name, extra = '') {
|
|
42
|
+
return `---\nname: ${name}\ndescription: test fixture\n---\n\n${extra}\n`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── 0. the shipped set is real, not empty ────────────────────────────────────
|
|
46
|
+
// If this is empty every other assertion below passes vacuously.
|
|
47
|
+
const shipped = shippedSkillNames();
|
|
48
|
+
assert.ok(shipped.size > 10, `shippedSkillNames() returned ${shipped.size} — too few to be real`);
|
|
49
|
+
assert.ok(shipped.has('build'), 'expected "build" among shipped skill names');
|
|
50
|
+
|
|
51
|
+
// ── 1. THE REGRESSION: a bare-named copy of a skill we ship is a duplicate ───
|
|
52
|
+
// This is precisely what the old startsWith('rdc:') predicate could not see.
|
|
53
|
+
{
|
|
54
|
+
const root = fresh();
|
|
55
|
+
const d = skillDir(root, 'build', fm('build', `> OUTPUT CONTRACT: \`${MARKER}\``));
|
|
56
|
+
assert.equal(isRdcSkillDuplicate(d, join(d, 'SKILL.md')), true,
|
|
57
|
+
'a bare-named copy of a shipped skill must be detected as a duplicate');
|
|
58
|
+
const removed = cleanUserSkills(root);
|
|
59
|
+
assert.equal(removed, 1, 'cleanUserSkills should remove exactly the duplicate');
|
|
60
|
+
assert.equal(existsSync(d), false, 'the duplicate directory should be gone');
|
|
61
|
+
rmSync(root, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── 2. legacy rdc:-prefixed entries still get cleaned ────────────────────────
|
|
65
|
+
// Pre-rename installs are the machines this function was originally written for.
|
|
66
|
+
{
|
|
67
|
+
const root = fresh();
|
|
68
|
+
const d = skillDir(root, 'rdc-build', fm('rdc:build'));
|
|
69
|
+
assert.equal(isRdcSkillDuplicate(d, join(d, 'SKILL.md')), true, 'legacy rdc: prefix must still match');
|
|
70
|
+
assert.equal(cleanUserSkills(root), 1);
|
|
71
|
+
assert.equal(existsSync(d), false);
|
|
72
|
+
rmSync(root, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── 3. SAFETY: a user's own skill with an unrelated name survives ────────────
|
|
76
|
+
{
|
|
77
|
+
const root = fresh();
|
|
78
|
+
const d = skillDir(root, 'leaflet-maps', fm('leaflet-maps'));
|
|
79
|
+
assert.equal(isRdcSkillDuplicate(d, join(d, 'SKILL.md')), false);
|
|
80
|
+
assert.equal(cleanUserSkills(root), 0, 'must not touch a user skill');
|
|
81
|
+
assert.equal(existsSync(d), true, "the user's own skill must survive");
|
|
82
|
+
rmSync(root, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── 4. SAFETY: a shipped NAME alone is not enough to delete ──────────────────
|
|
86
|
+
// Someone may legitimately write their own skill called "build". Name plus our
|
|
87
|
+
// marker is required; name alone must never be sufficient.
|
|
88
|
+
{
|
|
89
|
+
const root = fresh();
|
|
90
|
+
const d = skillDir(root, 'build', fm('build', 'my own build skill, nothing to do with rdc'));
|
|
91
|
+
assert.equal(isRdcSkillDuplicate(d, join(d, 'SKILL.md')), false,
|
|
92
|
+
'shipped name WITHOUT our marker must not be treated as ours');
|
|
93
|
+
assert.equal(cleanUserSkills(root), 0);
|
|
94
|
+
assert.equal(existsSync(d), true);
|
|
95
|
+
rmSync(root, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── 5. a symlink into our own skills/ tree is unambiguously ours ─────────────
|
|
99
|
+
// Skipped where the platform refuses symlink creation without privileges; the
|
|
100
|
+
// skip is loud, so it can never be mistaken for a pass.
|
|
101
|
+
{
|
|
102
|
+
const root = fresh();
|
|
103
|
+
const target = join(REPO_ROOT, 'skills', 'build');
|
|
104
|
+
let made = true;
|
|
105
|
+
try { symlinkSync(target, join(root, 'build'), 'junction'); }
|
|
106
|
+
catch { made = false; }
|
|
107
|
+
if (made) {
|
|
108
|
+
assert.equal(isRdcSkillDuplicate(join(root, 'build'), join(root, 'build', 'SKILL.md')), true,
|
|
109
|
+
'a symlink resolving inside our skills/ tree must be detected');
|
|
110
|
+
} else {
|
|
111
|
+
console.log(' SKIP symlink case — platform refused symlink creation');
|
|
112
|
+
}
|
|
113
|
+
rmSync(root, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── 6. cleanGlobalSkillsRoot: flat .md orphans at the top level ─────────────
|
|
117
|
+
{
|
|
118
|
+
const root = fresh();
|
|
119
|
+
const flat = join(root, 'build.md');
|
|
120
|
+
writeFileSync(flat, fm('build', `> OUTPUT CONTRACT: \`${MARKER}\``));
|
|
121
|
+
const mine = join(root, 'coolify-verify.md');
|
|
122
|
+
writeFileSync(mine, fm('coolify-verify'));
|
|
123
|
+
const removed = cleanGlobalSkillsRoot(root);
|
|
124
|
+
assert.equal(removed, 1, 'exactly the rdc orphan should go');
|
|
125
|
+
assert.equal(existsSync(flat), false, 'flat rdc orphan should be removed');
|
|
126
|
+
assert.equal(existsSync(mine), true, "the user's own flat skill must survive");
|
|
127
|
+
rmSync(root, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── 7. absent directory is not an error ──────────────────────────────────────
|
|
131
|
+
assert.equal(cleanUserSkills(join(tmpdir(), 'rdc-purge-does-not-exist')), 0);
|
|
132
|
+
assert.equal(cleanGlobalSkillsRoot(join(tmpdir(), 'rdc-purge-does-not-exist')), 0);
|
|
133
|
+
|
|
134
|
+
console.log('install-rdc-skills duplicate-purge test — PASS');
|