agentic-workflow-manager 4.0.1 → 4.1.1
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/dist/src/core/diagnostics/context.js +20 -3
- package/dist/src/core/diagnostics/provider-checks.js +22 -4
- package/dist/src/core/skill-integrity.js +33 -5
- package/dist/src/index.js +84 -22
- package/dist/tests/core/diagnostics/checks.test.js +3 -3
- package/dist/tests/core/diagnostics/context.test.js +2 -2
- package/dist/tests/core/diagnostics/provider-checks.test.js +3 -3
- package/dist/tests/core/diagnostics/provider-tier.test.js +8 -8
- package/dist/tests/core/init/steps-registry-sync.test.js +1 -1
- package/dist/tests/core/init/steps.test.js +4 -4
- package/dist/tests/core/skill-integrity.test.js +1 -1
- package/dist/tests/core/usurped-skill-links.test.js +126 -0
- package/package.json +1 -1
|
@@ -16,6 +16,7 @@ const status_1 = require("../../commands/hooks/status");
|
|
|
16
16
|
const profile_1 = require("../profile");
|
|
17
17
|
const bundles_1 = require("../bundles");
|
|
18
18
|
const skill_integrity_1 = require("../skill-integrity");
|
|
19
|
+
const artifact_state_1 = require("../artifact-state");
|
|
19
20
|
const paths_1 = require("../paths");
|
|
20
21
|
const provider_checks_1 = require("./provider-checks");
|
|
21
22
|
// Estado de un artefacto en <dir>/<skill>: link vivo / symlink colgante / ausente.
|
|
@@ -250,10 +251,21 @@ function gatherMachine(bundles, agent = 'claude-code', projectRoot) {
|
|
|
250
251
|
ambient: { wanted, installed },
|
|
251
252
|
contextInjection: gatherContextInjection(),
|
|
252
253
|
globalSkills: skillsDir !== null
|
|
253
|
-
? (0, skill_integrity_1.classifySkillLinks)(skillsDir, (0, registries_1.contentRoots)())
|
|
254
|
-
: { valid: [], repairable: [], dead: [] },
|
|
254
|
+
? (0, skill_integrity_1.classifySkillLinks)(skillsDir, (0, registries_1.contentRoots)(), (0, skill_integrity_1.managedLinkTargets)(safeReadArtifactState()))
|
|
255
|
+
: { valid: [], repairable: [], dead: [], usurped: [] },
|
|
255
256
|
};
|
|
256
257
|
}
|
|
258
|
+
/** `readArtifactState` tira si el JSON esta roto — correcto para un comando que va a
|
|
259
|
+
* ESCRIBIR sobre ese estado, inaceptable para uno que solo diagnostica. Aca degrada a
|
|
260
|
+
* "sin ledger": se pierde la deteccion de usurpaciones, no el resto del reporte. */
|
|
261
|
+
function safeReadArtifactState() {
|
|
262
|
+
try {
|
|
263
|
+
return (0, artifact_state_1.readArtifactState)();
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
257
269
|
function gatherProject(root, bundles, agent = 'claude-code') {
|
|
258
270
|
const profile = (0, profile_1.readProfile)(root);
|
|
259
271
|
const profilePresent = fs_1.default.existsSync(path_1.default.join(root, '.awm', 'profile.json'));
|
|
@@ -294,7 +306,12 @@ function gatherContext(opts = {}) {
|
|
|
294
306
|
const agent = opts.agent ?? 'claude-code';
|
|
295
307
|
const root = (0, profile_1.findProjectRoot)(cwd);
|
|
296
308
|
const agents = opts.agents ?? [agent];
|
|
297
|
-
|
|
309
|
+
// El ledger se lee UNA vez por corrida y se comparte con todos los dirs escaneados:
|
|
310
|
+
// es lo unico que distingue "el usuario puso este directorio" de "algo reemplazo
|
|
311
|
+
// nuestro symlink". Best-effort — un ledger ausente o corrupto degrada a "no puedo
|
|
312
|
+
// detectar usurpaciones", nunca revienta un comando de diagnostico.
|
|
313
|
+
const managed = (0, skill_integrity_1.managedLinkTargets)(safeReadArtifactState());
|
|
314
|
+
const scanSkills = opts.scanSkills ?? ((dir) => (0, skill_integrity_1.classifySkillLinks)(dir, (0, registries_1.contentRoots)(), managed));
|
|
298
315
|
return {
|
|
299
316
|
machine: gatherMachine(bundles, agent, root ?? undefined),
|
|
300
317
|
project: root ? gatherProject(root, bundles, agent) : null,
|
|
@@ -108,13 +108,20 @@ function skillsGlobalCheck(dir, owners, integrity, renderer) {
|
|
|
108
108
|
}
|
|
109
109
|
const shared = owners.length > 1;
|
|
110
110
|
const broken = integrity.repairable.length + integrity.dead.length;
|
|
111
|
+
// Usurpado ≠ roto: el link no cuelga, DESAPARECIO — otro instalador dejo un
|
|
112
|
+
// directorio real con el mismo nombre encima. El agente carga esa skill, no la
|
|
113
|
+
// nuestra, y hasta que esto se reporto el scan solo miraba symlinks, asi que el
|
|
114
|
+
// caso era literalmente invisible y `overall` decia `healthy`. No se auto-repara:
|
|
115
|
+
// borrar un directorio real con contenido de un tercero es destructivo y necesita
|
|
116
|
+
// que lo pida una persona.
|
|
117
|
+
const usurped = integrity.usurped.length;
|
|
111
118
|
// Broken links are checked BEFORE shared: 'shared' is a non-degrading/OK state
|
|
112
119
|
// (see checks.ts's DEGRADING_PROVIDER_STATES), so if it were set unconditionally
|
|
113
120
|
// for a shared dir it would silently mask real broken/dead symlinks — a green
|
|
114
121
|
// checkmark next to "N broken links → repair-global-skills" would contradict its
|
|
115
122
|
// own trailing text, and `overall` would never degrade despite real breakage.
|
|
116
123
|
let state;
|
|
117
|
-
if (broken > 0) {
|
|
124
|
+
if (broken > 0 || usurped > 0) {
|
|
118
125
|
state = 'broken';
|
|
119
126
|
}
|
|
120
127
|
else if (shared) {
|
|
@@ -131,8 +138,18 @@ function skillsGlobalCheck(dir, owners, integrity, renderer) {
|
|
|
131
138
|
state,
|
|
132
139
|
target: dir,
|
|
133
140
|
owners: shared ? owners : undefined,
|
|
134
|
-
detail:
|
|
135
|
-
|
|
141
|
+
detail: [
|
|
142
|
+
broken > 0 ? `${broken} broken links` : null,
|
|
143
|
+
usurped > 0
|
|
144
|
+
? `${usurped} replaced by non-AWM content (${integrity.usurped.join(', ')})`
|
|
145
|
+
: null,
|
|
146
|
+
].filter(Boolean).join('; ') || undefined,
|
|
147
|
+
// Una usurpacion NO la arregla `repair-global-skills` (solo toca symlinks
|
|
148
|
+
// colgantes), asi que ofrecer ese remedio seria mandar al usuario a un comando
|
|
149
|
+
// que no cambia nada. Reinstalar el bundle es lo que la resuelve.
|
|
150
|
+
remediationCode: usurped > 0
|
|
151
|
+
? 'reinstall-usurped-skills'
|
|
152
|
+
: broken > 0 ? 'repair-global-skills' : undefined,
|
|
136
153
|
};
|
|
137
154
|
}
|
|
138
155
|
/** R8: verify the Codex `.toml` agents this run's renderer would have produced still parse. */
|
|
@@ -334,7 +351,8 @@ function gatherProviderChecks(agents, scanSkills, projectRoot) {
|
|
|
334
351
|
const provider = (0, providers_1.providerFor)(agent);
|
|
335
352
|
const dir = provider.skill.global;
|
|
336
353
|
const owners = (dir !== null ? ownersByDir.get(dir) : undefined) ?? [agent];
|
|
337
|
-
const integrity = (dir !== null ? scansByDir.get(dir) : undefined)
|
|
354
|
+
const integrity = (dir !== null ? scansByDir.get(dir) : undefined)
|
|
355
|
+
?? { valid: [], repairable: [], dead: [], usurped: [] };
|
|
338
356
|
const checks = [
|
|
339
357
|
binaryVersionCheck(agent),
|
|
340
358
|
skillsGlobalCheck(dir, owners, integrity, provider.skill.renderer),
|
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.managedLinkTargets = managedLinkTargets;
|
|
6
7
|
exports.classifySkillLinks = classifySkillLinks;
|
|
7
8
|
exports.repairSkillLinks = repairSkillLinks;
|
|
8
9
|
exports.reconcileAllSkillLinks = reconcileAllSkillLinks;
|
|
@@ -19,12 +20,34 @@ function findRegistrySkillPath(registryContentDirs, name) {
|
|
|
19
20
|
}
|
|
20
21
|
return null;
|
|
21
22
|
}
|
|
23
|
+
/** Normaliza una ruta para comparar contra el ledger: absoluta siempre, y en
|
|
24
|
+
* Windows tambien case-insensitive, porque NTFS lo es y el ledger guarda la
|
|
25
|
+
* ruta con el casing que tuvo al instalarse, no el que tiene al leerse. */
|
|
26
|
+
function pathKey(p) {
|
|
27
|
+
const resolved = path_1.default.resolve(p);
|
|
28
|
+
return (0, paths_1.isWindowsNative)() ? resolved.toLowerCase() : resolved;
|
|
29
|
+
}
|
|
30
|
+
/** Targets que el ledger de artefactos declara instalados con el renderer `link`,
|
|
31
|
+
* normalizados con `pathKey` para comparar contra rutas del filesystem.
|
|
32
|
+
*
|
|
33
|
+
* Solo `link`: para un renderer que produce archivos reales (`cursor-mdc`,
|
|
34
|
+
* `copilot-instructions`) "no es un symlink" es lo esperado, no una usurpacion. */
|
|
35
|
+
function managedLinkTargets(records) {
|
|
36
|
+
return new Set(records.filter((r) => r.renderer === 'link').map((r) => pathKey(r.targetPath)));
|
|
37
|
+
}
|
|
22
38
|
/** Clasifica cada entrada de `skillsDir` (read-only, no muta nada).
|
|
23
39
|
* Agnostico al alcance: `skillsDir` es global (`provider.skill.global`) o de
|
|
24
40
|
* proyecto (`<projectRoot>/<provider.skill.local>`) — el nombre decia "Global" y
|
|
25
|
-
* eso basto para que nadie lo apuntara nunca a un dir de proyecto.
|
|
26
|
-
|
|
27
|
-
|
|
41
|
+
* eso basto para que nadie lo apuntara nunca a un dir de proyecto.
|
|
42
|
+
*
|
|
43
|
+
* `managedTargets` (opcional, ver `managedLinkTargets`) es lo que distingue
|
|
44
|
+
* "un directorio que el usuario puso ahi" de "un directorio que reemplazo un
|
|
45
|
+
* link nuestro". Sin el, el scan solo mira symlinks y una usurpacion es
|
|
46
|
+
* literalmente invisible: `awm doctor` reporta `healthy` mientras el agente
|
|
47
|
+
* carga la skill de otro. Por defecto vacio, para que los llamadores que no
|
|
48
|
+
* tienen ledger a mano conserven el comportamiento anterior. */
|
|
49
|
+
function classifySkillLinks(skillsDir, registryContentDirs, managedTargets = new Set()) {
|
|
50
|
+
const out = { valid: [], repairable: [], dead: [], usurped: [] };
|
|
28
51
|
let entries;
|
|
29
52
|
try {
|
|
30
53
|
entries = fs_1.default.readdirSync(skillsDir);
|
|
@@ -41,8 +64,13 @@ function classifySkillLinks(skillsDir, registryContentDirs) {
|
|
|
41
64
|
catch {
|
|
42
65
|
continue;
|
|
43
66
|
}
|
|
44
|
-
if (!lst.isSymbolicLink())
|
|
45
|
-
|
|
67
|
+
if (!lst.isSymbolicLink()) {
|
|
68
|
+
// Un dir/archivo real que puso el usuario no es nuestro problema. Uno
|
|
69
|
+
// que el ledger declara nuestro SI lo es — ahi hubo un reemplazo.
|
|
70
|
+
if (managedTargets.has(pathKey(p)))
|
|
71
|
+
out.usurped.push(name);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
46
74
|
if (fs_1.default.existsSync(p)) {
|
|
47
75
|
out.valid.push(name);
|
|
48
76
|
continue;
|
package/dist/src/index.js
CHANGED
|
@@ -477,18 +477,43 @@ program.command('list [package]')
|
|
|
477
477
|
console.log(picocolors_1.default.dim(` awm list <pkg> · awm list --all`));
|
|
478
478
|
(0, prompts_1.outro)(`Run ${picocolors_1.default.green('awm add')} to install artifacts.`);
|
|
479
479
|
});
|
|
480
|
-
|
|
481
|
-
|
|
480
|
+
// Simetrico con `add`: si se puede instalar scripteado, se tiene que poder desinstalar
|
|
481
|
+
// scripteado. `remove` era interactivo puro — sin nombre posicional, sin `--scope`, sin
|
|
482
|
+
// `--yes` — asi que cualquier limpieza automatizada quedaba bloqueada, y el playbook de
|
|
483
|
+
// aceptacion (CORE-17) scripteaba `awm remove dev --yes`, una invocacion que nunca
|
|
484
|
+
// existio. Tercera vez en esta sesion que un playbook se escribio contra lo que la doc
|
|
485
|
+
// prometia y no contra el comportamiento. Ver docs/decisions.md, D-006.
|
|
486
|
+
//
|
|
487
|
+
// El nombre es de BUNDLE, igual que en `add` (D-001).
|
|
488
|
+
program.command('remove [name]')
|
|
489
|
+
.description('Remove an installed bundle interactively, or non-interactively with flags')
|
|
482
490
|
.option('-a, --agent <agent>', `Target agent(s), comma-separated: ${providers_1.AGENT_TARGETS.join(', ')} (defaults to every enabled agent)`)
|
|
483
|
-
.
|
|
491
|
+
.option('-s, --scope <scope>', 'Scope: local or global (skips the prompt)')
|
|
492
|
+
.option('-y, --yes', 'Skip the confirmation prompt (requires a name)')
|
|
493
|
+
.action(async (name, options) => {
|
|
484
494
|
(0, prompts_1.intro)(picocolors_1.default.bgCyan(picocolors_1.default.black(' AWM - Remove Artifact ')));
|
|
485
495
|
const prefs = (0, config_1.getPreferences)();
|
|
496
|
+
// `--yes` sin nombre borraria lo que el usuario nunca eligio: un `rm -rf` silencioso
|
|
497
|
+
// sobre todo lo instalado. Se rechaza antes de tocar nada.
|
|
498
|
+
if (options.yes && !name) {
|
|
499
|
+
console.error(picocolors_1.default.red('--yes requires a bundle name: `awm remove <bundle> --yes`.'));
|
|
500
|
+
console.error(picocolors_1.default.dim('Without a name, removal stays interactive so you see what you are deleting.'));
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
486
503
|
// Multi-agent selection (matching the add command flow). --agent skips
|
|
487
504
|
// the interactive prompt and resolves the same way as add/sync/update/doctor (R12/R13).
|
|
488
505
|
let targetAgents;
|
|
489
506
|
if (options.agent) {
|
|
490
507
|
targetAgents = resolveTargetsOrExit(prefs, options.agent);
|
|
491
508
|
}
|
|
509
|
+
else if (options.yes) {
|
|
510
|
+
// `--yes` significa CERO prompts, no "cero confirmaciones". Sin esto, un
|
|
511
|
+
// `awm remove dev --yes` sin `--agent` seguia abriendo el multiselect de
|
|
512
|
+
// agentes y colgaba cualquier script — el flag prometia no-interactivo y no lo
|
|
513
|
+
// era. Sin `--agent`, el default son los agentes habilitados, igual que en
|
|
514
|
+
// `add`, `sync`, `update` y `doctor`.
|
|
515
|
+
targetAgents = [...prefs.enabledAgents];
|
|
516
|
+
}
|
|
492
517
|
else {
|
|
493
518
|
const agentChoice = await (0, prompts_1.multiselect)({
|
|
494
519
|
message: 'From which agent(s)?',
|
|
@@ -502,16 +527,26 @@ program.command('remove')
|
|
|
502
527
|
handleCancel(agentChoice);
|
|
503
528
|
targetAgents = agentChoice;
|
|
504
529
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
options
|
|
508
|
-
{
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
|
|
530
|
+
let scopeVal;
|
|
531
|
+
if (options.scope) {
|
|
532
|
+
if (options.scope !== 'local' && options.scope !== 'global') {
|
|
533
|
+
console.error(picocolors_1.default.red(`Invalid scope "${options.scope}". Use: local or global.`));
|
|
534
|
+
process.exit(1);
|
|
535
|
+
}
|
|
536
|
+
scopeVal = options.scope;
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
const scopeChoice = await (0, prompts_1.select)({
|
|
540
|
+
message: 'Scope?',
|
|
541
|
+
options: [
|
|
542
|
+
{ value: 'local', label: 'Project (Local)' },
|
|
543
|
+
{ value: 'global', label: 'Global' }
|
|
544
|
+
],
|
|
545
|
+
initialValue: prefs.defaultScope
|
|
546
|
+
});
|
|
547
|
+
handleCancel(scopeChoice);
|
|
548
|
+
scopeVal = scopeChoice;
|
|
549
|
+
}
|
|
515
550
|
// projectRoot explicito: `config.local` es relativo, y resolverlo contra
|
|
516
551
|
// `process.cwd()` hacia que `awm remove` no encontrara nada desde un
|
|
517
552
|
// subdirectorio — y que le pasara rutas RELATIVAS a fs.rmSync cuando si.
|
|
@@ -532,16 +567,43 @@ program.command('remove')
|
|
|
532
567
|
}
|
|
533
568
|
return `${icons} ${c.baseName} ${picocolors_1.default.dim(`(in: ${Array.from(locations).join(', ')})`)}`;
|
|
534
569
|
});
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
570
|
+
let resolved;
|
|
571
|
+
if (name) {
|
|
572
|
+
// Los artefactos del bundle pedido que estan REALMENTE instalados en este
|
|
573
|
+
// scope. Se cruza contra `installed`: lo que no esta instalado no se puede
|
|
574
|
+
// remover, y decirlo es mejor que borrar un subconjunto en silencio.
|
|
575
|
+
const bundle = (0, bundles_1.discoverAllBundles)().find((b) => b.name === name);
|
|
576
|
+
if (!bundle) {
|
|
577
|
+
console.error(picocolors_1.default.red(`Bundle "${name}" not found in registry.`));
|
|
578
|
+
console.error(picocolors_1.default.dim('Run `awm list` to see available packages.'));
|
|
579
|
+
process.exit(1);
|
|
580
|
+
}
|
|
581
|
+
const owned = new Set([
|
|
582
|
+
...bundle.skills.map((sk) => sk.name),
|
|
583
|
+
...bundle.workflows,
|
|
584
|
+
...bundle.agents,
|
|
585
|
+
]);
|
|
586
|
+
resolved = installed.filter((a) => owned.has(a.name));
|
|
587
|
+
if (resolved.length === 0) {
|
|
588
|
+
(0, prompts_1.outro)(picocolors_1.default.yellow(`Nothing from "${name}" is installed at ${scopeVal} scope for the selected agent(s).`));
|
|
589
|
+
process.exit(0);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
const toRemove = await (0, prompts_1.multiselect)({
|
|
594
|
+
message: 'Select artifact(s) to remove',
|
|
595
|
+
options: groupedOpts,
|
|
596
|
+
required: true
|
|
597
|
+
});
|
|
598
|
+
handleCancel(toRemove);
|
|
599
|
+
resolved = resolveSelectedArtifacts(toRemove);
|
|
600
|
+
}
|
|
542
601
|
const names = resolved.map(a => picocolors_1.default.red(a.name)).join(', ');
|
|
543
|
-
|
|
544
|
-
|
|
602
|
+
// `--yes` salta la confirmacion, nunca la seleccion: lo que se borra siempre sale
|
|
603
|
+
// de un nombre que el usuario escribio.
|
|
604
|
+
const confirmRemove = options.yes ? true : await (0, prompts_1.confirm)({ message: `Remove ${names}?` });
|
|
605
|
+
if (!options.yes)
|
|
606
|
+
handleCancel(confirmRemove);
|
|
545
607
|
if (confirmRemove) {
|
|
546
608
|
try {
|
|
547
609
|
for (const artifact of resolved) {
|
|
@@ -8,7 +8,7 @@ function healthyMachine() {
|
|
|
8
8
|
devCore: { present: true, brokenLinks: [] },
|
|
9
9
|
ambient: { wanted: [], installed: [] },
|
|
10
10
|
contextInjection: [],
|
|
11
|
-
globalSkills: { valid: [], repairable: [], dead: [] },
|
|
11
|
+
globalSkills: { valid: [], repairable: [], dead: [], usurped: [] },
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
function healthyProject() {
|
|
@@ -173,12 +173,12 @@ describe('machineChecks — global skill integrity', () => {
|
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
it('ok when no broken global skill links', () => {
|
|
176
|
-
const report = (0, checks_1.runChecks)(machineCtx({ valid: ['a'], repairable: [], dead: [] }));
|
|
176
|
+
const report = (0, checks_1.runChecks)(machineCtx({ valid: ['a'], repairable: [], dead: [], usurped: [] }));
|
|
177
177
|
const row = report.results.find((r) => r.id === 'machine.globalSkills');
|
|
178
178
|
expect(row?.status).toBe('ok');
|
|
179
179
|
});
|
|
180
180
|
it('warns with awm init remedy when there are broken links', () => {
|
|
181
|
-
const report = (0, checks_1.runChecks)(machineCtx({ valid: ['a'], repairable: ['b'], dead: ['c'] }));
|
|
181
|
+
const report = (0, checks_1.runChecks)(machineCtx({ valid: ['a'], repairable: ['b'], dead: ['c'], usurped: [] }));
|
|
182
182
|
const row = report.results.find((r) => r.id === 'machine.globalSkills');
|
|
183
183
|
expect(row?.status).toBe('warn');
|
|
184
184
|
expect(row?.detail).toContain('2'); // 1 repairable + 1 dead
|
|
@@ -240,7 +240,7 @@ describe('gatherContext — providers matrix (Task 9)', () => {
|
|
|
240
240
|
process.env.AWM_HOME = originalAwmHome;
|
|
241
241
|
});
|
|
242
242
|
function healthySharedSkills() {
|
|
243
|
-
return { valid: ['development-process'], repairable: [], dead: [] };
|
|
243
|
+
return { valid: ['development-process'], repairable: [], dead: [], usurped: [] };
|
|
244
244
|
}
|
|
245
245
|
it('reports shared skills for both owners without scanning twice', () => {
|
|
246
246
|
// OpenCode and Codex both read/write ~/.agents/skills (providers/index.ts) —
|
|
@@ -252,7 +252,7 @@ describe('gatherContext — providers matrix (Task 9)', () => {
|
|
|
252
252
|
expect(report.providers.every((provider) => provider.checks.some((check) => check.state === 'shared'))).toBe(true);
|
|
253
253
|
});
|
|
254
254
|
it('marks skills.global healthy (not shared) for a single unshared provider', () => {
|
|
255
|
-
const scan = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
255
|
+
const scan = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
256
256
|
const { gatherContext } = require('../../../src/core/diagnostics/context');
|
|
257
257
|
const report = gatherContext({ cwd: tmpHome, bundles: [], agents: ['claude-code'], scanSkills: scan });
|
|
258
258
|
expect(scan).toHaveBeenCalledTimes(1);
|
|
@@ -177,7 +177,7 @@ describe('gatherProviderChecks — shared skills.global does not mask broken lin
|
|
|
177
177
|
const { computeProviderOverall } = require('../../../src/core/diagnostics/checks');
|
|
178
178
|
// OpenCode and Codex share ~/.agents/skills (providers/index.ts). Stub the
|
|
179
179
|
// scan to report 2 broken/dead links on that shared directory.
|
|
180
|
-
const scanSkills = jest.fn(() => ({ valid: ['ok-skill'], repairable: ['stale-skill'], dead: ['ghost-skill'] }));
|
|
180
|
+
const scanSkills = jest.fn(() => ({ valid: ['ok-skill'], repairable: ['stale-skill'], dead: ['ghost-skill'], usurped: [] }));
|
|
181
181
|
const ctx = gatherContext({ cwd: tmpHome, bundles: [], agents: ['opencode', 'codex'], scanSkills });
|
|
182
182
|
expect(scanSkills).toHaveBeenCalledTimes(1);
|
|
183
183
|
for (const provider of ctx.providers) {
|
|
@@ -192,7 +192,7 @@ describe('gatherProviderChecks — shared skills.global does not mask broken lin
|
|
|
192
192
|
it('still reports shared (healthy, non-degrading) when the shared dir has no broken links', () => {
|
|
193
193
|
const { gatherContext } = require('../../../src/core/diagnostics/context');
|
|
194
194
|
const { computeProviderOverall } = require('../../../src/core/diagnostics/checks');
|
|
195
|
-
const scanSkills = jest.fn(() => ({ valid: ['ok-skill'], repairable: [], dead: [] }));
|
|
195
|
+
const scanSkills = jest.fn(() => ({ valid: ['ok-skill'], repairable: [], dead: [], usurped: [] }));
|
|
196
196
|
const ctx = gatherContext({ cwd: tmpHome, bundles: [], agents: ['opencode', 'codex'], scanSkills });
|
|
197
197
|
for (const provider of ctx.providers) {
|
|
198
198
|
const skillsCheck = provider.checks.find((c) => c.id === 'skills.global');
|
|
@@ -237,7 +237,7 @@ describe('gatherProviderChecks — agents.native reports broken on a malformed C
|
|
|
237
237
|
fs_1.default.mkdirSync(agentsDir, { recursive: true });
|
|
238
238
|
fs_1.default.writeFileSync(path_1.default.join(agentsDir, 'development-process.toml'), 'name = "development-process"\n# missing the developer_instructions key entirely\n');
|
|
239
239
|
const { gatherContext } = require('../../../src/core/diagnostics/context');
|
|
240
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
240
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
241
241
|
const ctx = gatherContext({ cwd: tmpHome, bundles: [], agents: ['codex'], scanSkills });
|
|
242
242
|
const codex = ctx.providers.find((p) => p.id === 'codex');
|
|
243
243
|
const agentsNative = codex.checks.find((c) => c.id === 'agents.native');
|
|
@@ -51,7 +51,7 @@ describe('contextGlobalCheck — scope-aware (Task 4.4 / deferred Task 4.2 findi
|
|
|
51
51
|
return root;
|
|
52
52
|
}
|
|
53
53
|
function scanSkillsStub() {
|
|
54
|
-
return jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
54
|
+
return jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
55
55
|
}
|
|
56
56
|
beforeEach(() => {
|
|
57
57
|
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-tier-home-'));
|
|
@@ -177,7 +177,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
177
177
|
// classifySkillLinks only ever sees symlinks (`if (!lst.isSymbolicLink()) continue;`)
|
|
178
178
|
// — a real scan over rulesDir would find nothing here either. Stubbed explicitly so the
|
|
179
179
|
// test proves the FIX (renderer-gating), not an accident of what classifySkillLinks does.
|
|
180
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
180
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
181
181
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
182
182
|
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
183
183
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -216,7 +216,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
216
216
|
});
|
|
217
217
|
const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
|
|
218
218
|
expect(fs_1.default.existsSync(path_1.default.join(rulesDir, 'using-awm.mdc'))).toBe(true);
|
|
219
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
219
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
220
220
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
221
221
|
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
222
222
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -225,7 +225,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
225
225
|
fs_1.default.rmSync(content, { recursive: true, force: true });
|
|
226
226
|
});
|
|
227
227
|
it('non-link renderer with an empty/missing dir reports absent, not a false healthy', () => {
|
|
228
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
228
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
229
229
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
230
230
|
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
231
231
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -234,7 +234,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
234
234
|
it('link renderer (claude-code) behavior is completely unchanged — regression', () => {
|
|
235
235
|
const skillsDir = path_1.default.join(tmpHome, '.claude/skills');
|
|
236
236
|
fs_1.default.mkdirSync(skillsDir, { recursive: true });
|
|
237
|
-
const scanSkills = jest.fn(() => ({ valid: ['using-awm'], repairable: [], dead: [] }));
|
|
237
|
+
const scanSkills = jest.fn(() => ({ valid: ['using-awm'], repairable: [], dead: [], usurped: [] }));
|
|
238
238
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
239
239
|
const facts = gatherProviderChecks(['claude-code'], scanSkills);
|
|
240
240
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -244,7 +244,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
244
244
|
it('link renderer (claude-code) still reports broken links — regression', () => {
|
|
245
245
|
const skillsDir = path_1.default.join(tmpHome, '.claude/skills');
|
|
246
246
|
fs_1.default.mkdirSync(skillsDir, { recursive: true });
|
|
247
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: ['stale-skill'], dead: [] }));
|
|
247
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: ['stale-skill'], dead: [], usurped: [] }));
|
|
248
248
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
249
249
|
const facts = gatherProviderChecks(['claude-code'], scanSkills);
|
|
250
250
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -263,7 +263,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
263
263
|
// neither ends in `.mdc`, so neither is AWM-shaped evidence.
|
|
264
264
|
fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'notes.txt'), 'my own notes, not an AWM rule');
|
|
265
265
|
fs_1.default.mkdirSync(path_1.default.join(rulesDir, 'some-user-dir'));
|
|
266
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
266
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
267
267
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
268
268
|
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
269
269
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -274,7 +274,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
274
274
|
fs_1.default.mkdirSync(rulesDir, { recursive: true });
|
|
275
275
|
fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'notes.txt'), 'my own notes, not an AWM rule');
|
|
276
276
|
fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'foo.mdc'), '---\ndescription: foo\nglobs:\nalwaysApply: false\n---\n\nBody.');
|
|
277
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
277
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
278
278
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
279
279
|
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
280
280
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -61,7 +61,7 @@ describe('stepCache — registry sync error results', () => {
|
|
|
61
61
|
devCore: { present: true, brokenLinks: [] },
|
|
62
62
|
ambient: { wanted: [], installed: [] },
|
|
63
63
|
contextInjection: [],
|
|
64
|
-
globalSkills: { valid: [], repairable: [], dead: [] },
|
|
64
|
+
globalSkills: { valid: [], repairable: [], dead: [], usurped: [] },
|
|
65
65
|
},
|
|
66
66
|
project: null,
|
|
67
67
|
};
|
|
@@ -23,7 +23,7 @@ function machine() {
|
|
|
23
23
|
devCore: { present: true, brokenLinks: [] },
|
|
24
24
|
ambient: { wanted: [], installed: [] },
|
|
25
25
|
contextInjection: [],
|
|
26
|
-
globalSkills: { valid: [], repairable: [], dead: [] },
|
|
26
|
+
globalSkills: { valid: [], repairable: [], dead: [], usurped: [] },
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
function project(over = {}) {
|
|
@@ -429,7 +429,7 @@ describe('stepGlobalSkillsRepair', () => {
|
|
|
429
429
|
const a = spies();
|
|
430
430
|
a.repairGlobalSkills = jest.fn(() => ({ relinked: ['b'], pruned: ['c'], failed: [] }));
|
|
431
431
|
const m = machine();
|
|
432
|
-
m.globalSkills = { valid: ['a'], repairable: ['b'], dead: ['c'] };
|
|
432
|
+
m.globalSkills = { valid: ['a'], repairable: ['b'], dead: ['c'], usurped: [] };
|
|
433
433
|
const r = (0, steps_1.stepGlobalSkillsRepair)(deps({ machine: m, project: null }, a));
|
|
434
434
|
expect(r.action).toBe('applied');
|
|
435
435
|
expect(a.repairGlobalSkills).toHaveBeenCalledTimes(1);
|
|
@@ -440,7 +440,7 @@ describe('stepGlobalSkillsRepair', () => {
|
|
|
440
440
|
const a = spies();
|
|
441
441
|
a.repairGlobalSkills = jest.fn(() => ({ relinked: ['b'], pruned: [], failed: [] }));
|
|
442
442
|
const m = machine();
|
|
443
|
-
m.globalSkills = { valid: [], repairable: ['b'], dead: [] };
|
|
443
|
+
m.globalSkills = { valid: [], repairable: ['b'], dead: [], usurped: [] };
|
|
444
444
|
const r = (0, steps_1.stepGlobalSkillsRepair)(deps({ machine: m, project: null }, a, { agent: 'opencode' }));
|
|
445
445
|
expect(r.action).toBe('applied');
|
|
446
446
|
expect(a.repairGlobalSkills).toHaveBeenCalledWith((0, providers_1.providerFor)('opencode').skill.global, expect.any(Array));
|
|
@@ -451,7 +451,7 @@ describe('stepGlobalSkillsRepair', () => {
|
|
|
451
451
|
const m = machine();
|
|
452
452
|
// Broken-count is nonzero, so the ONLY thing that can make this skip is the
|
|
453
453
|
// null-global-dir guard itself, not the "nothing broken" early return above.
|
|
454
|
-
m.globalSkills = { valid: [], repairable: ['b'], dead: ['c'] };
|
|
454
|
+
m.globalSkills = { valid: [], repairable: ['b'], dead: ['c'], usurped: [] };
|
|
455
455
|
const r = (0, steps_1.stepGlobalSkillsRepair)(deps({ machine: m, project: null }, a, { agent: 'copilot' }));
|
|
456
456
|
expect(r.action).toBe('skipped');
|
|
457
457
|
expect(a.repairGlobalSkills).not.toHaveBeenCalled();
|
|
@@ -71,7 +71,7 @@ describe('classifySkillLinks', () => {
|
|
|
71
71
|
});
|
|
72
72
|
it('returns empty arrays when the skills dir does not exist', () => {
|
|
73
73
|
const result = (0, skill_integrity_1.classifySkillLinks)('/nonexistent/dir', ['/also/nonexistent']);
|
|
74
|
-
expect(result).toEqual({ valid: [], repairable: [], dead: [] });
|
|
74
|
+
expect(result).toEqual({ valid: [], repairable: [], dead: [], usurped: [] });
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
describe('reconcileAllSkillLinks (#4 — awm update, all providers)', () => {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
// Un symlink gestionado por AWM que otro instalador reemplaza por un directorio real
|
|
7
|
+
// era INVISIBLE para todo el sistema de diagnostico.
|
|
8
|
+
//
|
|
9
|
+
// Encontrado corriendo el playbook `agent-matrix` para `claude-code` contra el binario
|
|
10
|
+
// publicado: `awm init` dejo `~/.claude/skills/mermaid-diagrams` como symlink al registry
|
|
11
|
+
// baseline y lo anoto en `state/artifacts.json`; la primera sesion real de Claude Code
|
|
12
|
+
// materializo su propia skill `mermaid-diagrams` (bundled, mismo nombre) encima, pisando
|
|
13
|
+
// el symlink con un directorio real distinto — otra `description`, sin `version`, con un
|
|
14
|
+
// README.md que la nuestra no tiene.
|
|
15
|
+
//
|
|
16
|
+
// Despues de eso:
|
|
17
|
+
// - `awm doctor -a claude-code` reportaba `skills.global: healthy`, `overall: healthy`,
|
|
18
|
+
// exit 0;
|
|
19
|
+
// - `awm sync` no lo tocaba;
|
|
20
|
+
// - el agente cargaba la skill del tercero, no la instalada.
|
|
21
|
+
//
|
|
22
|
+
// La causa es una sola linea de `classifySkillLinks`: `if (!lst.isSymbolicLink()) continue`.
|
|
23
|
+
// Correcta para una skill que el usuario puso a mano — AWM no debe tocarla — y equivocada
|
|
24
|
+
// cuando el ledger de artefactos dice que esa ruta exacta es nuestra. El clasificador nunca
|
|
25
|
+
// consultaba el ledger, asi que no podia distinguir los dos casos.
|
|
26
|
+
const fs_1 = __importDefault(require("fs"));
|
|
27
|
+
const path_1 = __importDefault(require("path"));
|
|
28
|
+
const skill_integrity_1 = require("../../src/core/skill-integrity");
|
|
29
|
+
const tmp_1 = require("../support/tmp");
|
|
30
|
+
describe('a managed skill link replaced by third-party content is detected', () => {
|
|
31
|
+
let registry;
|
|
32
|
+
let skillsDir;
|
|
33
|
+
const made = [];
|
|
34
|
+
const record = (targetPath, renderer = 'link') => ({
|
|
35
|
+
name: path_1.default.basename(targetPath),
|
|
36
|
+
type: 'skill',
|
|
37
|
+
scope: 'global',
|
|
38
|
+
targetPath,
|
|
39
|
+
sourcePath: path_1.default.join(registry, 'skills', path_1.default.basename(targetPath)),
|
|
40
|
+
renderer,
|
|
41
|
+
owners: ['claude-code'],
|
|
42
|
+
});
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
registry = (0, tmp_1.mkCanonicalTmpDir)('awm-usurp-reg-');
|
|
45
|
+
skillsDir = (0, tmp_1.mkCanonicalTmpDir)('awm-usurp-skills-');
|
|
46
|
+
made.push(registry, skillsDir);
|
|
47
|
+
fs_1.default.mkdirSync(path_1.default.join(registry, 'skills', 'mermaid-diagrams'), { recursive: true });
|
|
48
|
+
fs_1.default.writeFileSync(path_1.default.join(registry, 'skills', 'mermaid-diagrams', 'SKILL.md'), '# awm\n');
|
|
49
|
+
});
|
|
50
|
+
afterAll(() => { for (const d of made)
|
|
51
|
+
fs_1.default.rmSync(d, { recursive: true, force: true }); });
|
|
52
|
+
/** Lo que hace el tercero: borra nuestro symlink y deja su propio directorio. */
|
|
53
|
+
function usurp(name) {
|
|
54
|
+
const p = path_1.default.join(skillsDir, name);
|
|
55
|
+
fs_1.default.rmSync(p, { recursive: true, force: true });
|
|
56
|
+
fs_1.default.mkdirSync(p, { recursive: true });
|
|
57
|
+
fs_1.default.writeFileSync(path_1.default.join(p, 'SKILL.md'), '# someone else\n');
|
|
58
|
+
return p;
|
|
59
|
+
}
|
|
60
|
+
it('reports the replaced entry as usurped, not as healthy', () => {
|
|
61
|
+
const target = path_1.default.join(skillsDir, 'mermaid-diagrams');
|
|
62
|
+
fs_1.default.symlinkSync(path_1.default.join(registry, 'skills', 'mermaid-diagrams'), target);
|
|
63
|
+
// Antes de la usurpacion: un link vivo y nada mas.
|
|
64
|
+
const managed = (0, skill_integrity_1.managedLinkTargets)([record(target)]);
|
|
65
|
+
const before = (0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry], managed);
|
|
66
|
+
expect(before.valid).toEqual(['mermaid-diagrams']);
|
|
67
|
+
expect(before.usurped).toEqual([]);
|
|
68
|
+
usurp('mermaid-diagrams');
|
|
69
|
+
const after = (0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry], managed);
|
|
70
|
+
expect(after.usurped).toEqual(['mermaid-diagrams']);
|
|
71
|
+
// Y no se cuela por ninguna de las categorias existentes: `valid` es lo que hace
|
|
72
|
+
// que `doctor` diga `healthy`, y `repairable`/`dead` mandarian a un remedio
|
|
73
|
+
// (`repair-global-skills`) que solo toca symlinks y aca no cambiaria nada.
|
|
74
|
+
expect(after.valid).toEqual([]);
|
|
75
|
+
expect(after.repairable).toEqual([]);
|
|
76
|
+
expect(after.dead).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
it('leaves a directory the user created alone — it is not in the ledger', () => {
|
|
79
|
+
usurp('my-own-skill');
|
|
80
|
+
const scan = (0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry], (0, skill_integrity_1.managedLinkTargets)([]));
|
|
81
|
+
expect(scan.usurped).toEqual([]);
|
|
82
|
+
expect(scan.valid).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
it('does not flag a rendered artifact: a real file is what `cursor-mdc` produces', () => {
|
|
85
|
+
// El ledger tambien anota artefactos renderizados (`.mdc`, `.instructions.md`).
|
|
86
|
+
// Para esos, "no es un symlink" es el estado correcto — contarlos como usurpados
|
|
87
|
+
// pintaria de rojo cada instalacion sana de Cursor y Copilot.
|
|
88
|
+
const target = path_1.default.join(skillsDir, 'rendered.mdc');
|
|
89
|
+
fs_1.default.writeFileSync(target, '---\ndescription: x\n---\n');
|
|
90
|
+
const managed = (0, skill_integrity_1.managedLinkTargets)([record(target, 'cursor-mdc')]);
|
|
91
|
+
expect(managed.size).toBe(0);
|
|
92
|
+
expect((0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry], managed).usurped).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
it('defaults to no detection when no ledger is passed (callers without one are unchanged)', () => {
|
|
95
|
+
usurp('mermaid-diagrams');
|
|
96
|
+
expect((0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry]).usurped).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
// El hallazgo del playbook no fue "el clasificador no ve X" — fue "doctor dice healthy".
|
|
100
|
+
// Detectarlo en `classifySkillLinks` no sirve de nada si el check que lo consume lo
|
|
101
|
+
// descarta, asi que la superficie que reporto el bug se asserta por separado.
|
|
102
|
+
describe('awm doctor degrades on a usurped global skill', () => {
|
|
103
|
+
const { gatherProviderChecks } = require('../../src/core/diagnostics/provider-checks');
|
|
104
|
+
function checkFor(usurped, repairable = []) {
|
|
105
|
+
const scan = jest.fn(() => ({ valid: [], repairable, dead: [], usurped }));
|
|
106
|
+
const facts = gatherProviderChecks(['claude-code'], scan);
|
|
107
|
+
return facts[0].checks.find((c) => c.id === 'skills.global');
|
|
108
|
+
}
|
|
109
|
+
it('is broken, names what was replaced, and does not offer a remedy that cannot fix it', () => {
|
|
110
|
+
const check = checkFor(['mermaid-diagrams']);
|
|
111
|
+
expect(check.state).toBe('broken');
|
|
112
|
+
expect(check.detail).toContain('mermaid-diagrams');
|
|
113
|
+
// `repair-global-skills` solo re-linkea symlinks colgantes: mandaria al usuario a
|
|
114
|
+
// un comando que corre limpio y no cambia nada.
|
|
115
|
+
expect(check.remediationCode).toBe('reinstall-usurped-skills');
|
|
116
|
+
});
|
|
117
|
+
it('still reports plain broken links the old way when nothing was usurped', () => {
|
|
118
|
+
const check = checkFor([], ['gone']);
|
|
119
|
+
expect(check.state).toBe('broken');
|
|
120
|
+
expect(check.detail).toBe('1 broken links');
|
|
121
|
+
expect(check.remediationCode).toBe('repair-global-skills');
|
|
122
|
+
});
|
|
123
|
+
it('is healthy when neither happened', () => {
|
|
124
|
+
expect(checkFor([]).state).not.toBe('broken');
|
|
125
|
+
});
|
|
126
|
+
});
|